diff --git a/.gitattributes b/.gitattributes index 84c7add05..14c3c3594 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ /Tests export-ignore /phpunit.xml.dist export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..4689c4dad --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/.github/workflows/close-pull-request.yml b/.github/workflows/close-pull-request.yml new file mode 100644 index 000000000..e55b47817 --- /dev/null +++ b/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/Attribute/Context.php b/Attribute/Context.php index 61ff1e79d..892af481a 100644 --- a/Attribute/Context.php +++ b/Attribute/Context.php @@ -22,7 +22,10 @@ class Context private array $groups; /** - * @param string|string[] $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 */ @@ -33,14 +36,14 @@ public function __construct( 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)); + 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))); + 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))); } } } diff --git a/Attribute/DiscriminatorMap.php b/Attribute/DiscriminatorMap.php index 31b9eee7e..48d0842aa 100644 --- a/Attribute/DiscriminatorMap.php +++ b/Attribute/DiscriminatorMap.php @@ -19,16 +19,22 @@ #[\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 (empty($typeProperty)) { - throw new InvalidArgumentException(sprintf('Parameter "typeProperty" given to "%s" cannot be empty.', static::class)); + if (!$typeProperty) { + throw new InvalidArgumentException(\sprintf('Parameter "typeProperty" given to "%s" cannot be empty.', static::class)); } - if (empty($mapping)) { - throw new InvalidArgumentException(sprintf('Parameter "mapping" given to "%s" cannot be empty.', static::class)); + if (!$mapping) { + throw new InvalidArgumentException(\sprintf('Parameter "mapping" given to "%s" cannot be empty.', static::class)); } } diff --git a/Attribute/Groups.php b/Attribute/Groups.php index 1c9c9d025..8747949a4 100644 --- a/Attribute/Groups.php +++ b/Attribute/Groups.php @@ -25,19 +25,19 @@ class Groups private readonly array $groups; /** - * @param string|string[] $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)); + 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)); + throw new InvalidArgumentException(\sprintf('Parameter given to "%s" must be a string or an array of non-empty strings.', static::class)); } } } diff --git a/Attribute/MaxDepth.php b/Attribute/MaxDepth.php index f132c87a7..16d6a8120 100644 --- a/Attribute/MaxDepth.php +++ b/Attribute/MaxDepth.php @@ -19,10 +19,13 @@ #[\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)); + throw new InvalidArgumentException(\sprintf('Parameter given to "%s" must be a positive integer.', static::class)); } } diff --git a/Attribute/SerializedName.php b/Attribute/SerializedName.php index 265aea967..f1c6cefe2 100644 --- a/Attribute/SerializedName.php +++ b/Attribute/SerializedName.php @@ -19,10 +19,13 @@ #[\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)); + throw new InvalidArgumentException(\sprintf('Parameter given to "%s" must be a non-empty string.', self::class)); } } diff --git a/Attribute/SerializedPath.php b/Attribute/SerializedPath.php index 58a35b6db..71254bfb9 100644 --- a/Attribute/SerializedPath.php +++ b/Attribute/SerializedPath.php @@ -23,12 +23,15 @@ 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)); + throw new InvalidArgumentException(\sprintf('Parameter given to "%s" must be a valid property path.', self::class)); } } diff --git a/CHANGELOG.md b/CHANGELOG.md index b329cf154..4c36d5885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ 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 --- diff --git a/Command/DebugCommand.php b/Command/DebugCommand.php index 13873dd1f..7df4d6bc8 100644 --- a/Command/DebugCommand.php +++ b/Command/DebugCommand.php @@ -49,7 +49,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!class_exists($class)) { $io = new SymfonyStyle($input, $output); - $io->error(sprintf('Class "%s" was not found.', $class)); + $io->error(\sprintf('Class "%s" was not found.', $class)); return Command::FAILURE; } @@ -62,7 +62,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function dumpSerializerDataForClass(InputInterface $input, OutputInterface $output, string $class): void { $io = new SymfonyStyle($input, $output); - $title = sprintf('%s', $class); + $title = \sprintf('%s', $class); $rows = []; $dump = new Dumper($output); @@ -75,15 +75,14 @@ private function dumpSerializerDataForClass(InputInterface $input, OutputInterfa ]; } + $io->section($title); + if (!$rows) { - $io->section($title); $io->text('No Serializer data were found for this class.'); return; } - $io->section($title); - $table = new Table($output); $table->setHeaders(['Property', 'Options']); $table->setRows($rows); @@ -102,6 +101,7 @@ private function getAttributesData(ClassMetadataInterface $classMetadata): array '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(), diff --git a/Context/Encoder/CsvEncoderContextBuilder.php b/Context/Encoder/CsvEncoderContextBuilder.php index f75759031..9f0d6da6f 100644 --- a/Context/Encoder/CsvEncoderContextBuilder.php +++ b/Context/Encoder/CsvEncoderContextBuilder.php @@ -35,7 +35,7 @@ final class CsvEncoderContextBuilder implements ContextBuilderInterface 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)); + throw new InvalidArgumentException(\sprintf('The "%s" delimiter must be a single character.', $delimiter)); } return $this->with(CsvEncoder::DELIMITER_KEY, $delimiter); @@ -51,7 +51,7 @@ public function withDelimiter(?string $delimiter): static 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)); + throw new InvalidArgumentException(\sprintf('The "%s" enclosure must be a single character.', $enclosure)); } return $this->with(CsvEncoder::ENCLOSURE_KEY, $enclosure); @@ -62,12 +62,16 @@ public function withEnclosure(?string $enclosure): static * * 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)); + throw new InvalidArgumentException(\sprintf('The "%s" escape character must be empty or a single character.', $escapeChar)); } return $this->with(CsvEncoder::ESCAPE_CHAR_KEY, $escapeChar); diff --git a/Context/Encoder/XmlEncoderContextBuilder.php b/Context/Encoder/XmlEncoderContextBuilder.php index 34cf78198..0fd1f2f44 100644 --- a/Context/Encoder/XmlEncoderContextBuilder.php +++ b/Context/Encoder/XmlEncoderContextBuilder.php @@ -152,4 +152,12 @@ 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/Normalizer/AbstractNormalizerContextBuilder.php b/Context/Normalizer/AbstractNormalizerContextBuilder.php index f365ac8df..a63e1a507 100644 --- a/Context/Normalizer/AbstractNormalizerContextBuilder.php +++ b/Context/Normalizer/AbstractNormalizerContextBuilder.php @@ -87,7 +87,7 @@ public function withAttributes(?array $attributes): static foreach ($it as $attribute) { if (!\is_string($attribute)) { - throw new InvalidArgumentException(sprintf('Each attribute must be a string, "%s" given.', get_debug_type($attribute))); + throw new InvalidArgumentException(\sprintf('Each attribute must be a string, "%s" given.', get_debug_type($attribute))); } } @@ -103,6 +103,18 @@ 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. * @@ -117,14 +129,6 @@ public function withDefaultConstructorArguments(?array $defaultConstructorArgume return $this->with(AbstractNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS, $defaultConstructorArguments); } - /** - * Deprecated in Symfony 7.1, use withDefaultConstructorArguments() instead. - */ - public function withDefaultContructorArguments(?array $defaultContructorArguments): static - { - return self::withDefaultConstructorArguments($defaultContructorArguments); - } - /** * Configures an hashmap of field name => callable to normalize this field. * diff --git a/Context/Normalizer/AbstractObjectNormalizerContextBuilder.php b/Context/Normalizer/AbstractObjectNormalizerContextBuilder.php index a27f00c5b..45b47bc1b 100644 --- a/Context/Normalizer/AbstractObjectNormalizerContextBuilder.php +++ b/Context/Normalizer/AbstractObjectNormalizerContextBuilder.php @@ -47,7 +47,7 @@ public function withDepthKeyPattern(?string $depthKeyPattern): static 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)); + 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); diff --git a/Context/Normalizer/DateTimeNormalizerContextBuilder.php b/Context/Normalizer/DateTimeNormalizerContextBuilder.php index 99517afb1..de83b1245 100644 --- a/Context/Normalizer/DateTimeNormalizerContextBuilder.php +++ b/Context/Normalizer/DateTimeNormalizerContextBuilder.php @@ -55,10 +55,18 @@ public function withTimezone(\DateTimeZone|string|null $timezone): static try { $timezone = new \DateTimeZone($timezone); } catch (\Exception $e) { - throw new InvalidArgumentException(sprintf('The "%s" timezone is invalid.', $timezone), previous: $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/UidNormalizerContextBuilder.php b/Context/Normalizer/UidNormalizerContextBuilder.php index 1d889e502..b809fe3eb 100644 --- a/Context/Normalizer/UidNormalizerContextBuilder.php +++ b/Context/Normalizer/UidNormalizerContextBuilder.php @@ -33,7 +33,7 @@ final class UidNormalizerContextBuilder implements ContextBuilderInterface 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)); + 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 index 5beb4e984..2945cb961 100644 --- a/Context/Normalizer/UnwrappingDenormalizerContextBuilder.php +++ b/Context/Normalizer/UnwrappingDenormalizerContextBuilder.php @@ -45,7 +45,7 @@ public function withUnwrapPath(?string $unwrapPath): static try { new PropertyPath($unwrapPath); } catch (InvalidPropertyPathException $e) { - throw new InvalidArgumentException(sprintf('The "%s" property path is not valid.', $unwrapPath), previous: $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/DataCollector/SerializerDataCollector.php b/DataCollector/SerializerDataCollector.php index 671239d28..e87c51ca1 100644 --- a/DataCollector/SerializerDataCollector.php +++ b/DataCollector/SerializerDataCollector.php @@ -21,15 +21,26 @@ /** * @author Mathias Arlaud * - * @internal + * @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 = []; } @@ -43,14 +54,14 @@ public function getName(): string return 'serializer'; } - public function getData(): Data|array + public function getData(?string $name = null): Data|array { - return $this->data; + return null === $name ? $this->data : $this->getDataGroupedByName()[$name]; } - public function getHandledCount(): int + public function getHandledCount(?string $name = null): int { - return array_sum(array_map('count', $this->data)); + return array_sum(array_map('count', $this->getData($name))); } public function getTotalTime(): float @@ -64,110 +75,108 @@ public function getTotalTime(): float return $totalTime; } - public function collectSerialize(string $traceId, mixed $data, string $format, array $context, float $time, array $caller): void + 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'), + 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): void + 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'), + 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): void + 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'), + 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): void + 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'), + 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): void + 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'), + compact('data', 'format', 'context', 'time', 'caller', 'name'), ['method' => 'encode'], ); } - public function collectDecode(string $traceId, mixed $data, ?string $format, array $context, float $time, array $caller): void + 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'), + compact('data', 'format', 'context', 'time', 'caller', 'name'), ['method' => 'decode'], ); } - public function collectNormalization(string $traceId, string $normalizer, float $time): void + public function collectNormalization(string $traceId, string $normalizer, float $time, string $name): void { $method = 'normalize'; - $this->collected[$traceId]['normalization'][] = compact('normalizer', 'method', 'time'); + $this->collected[$traceId]['normalization'][] = compact('normalizer', 'method', 'time', 'name'); } - public function collectDenormalization(string $traceId, string $normalizer, float $time): void + public function collectDenormalization(string $traceId, string $normalizer, float $time, string $name): void { $method = 'denormalize'; - $this->collected[$traceId]['normalization'][] = compact('normalizer', 'method', 'time'); + $this->collected[$traceId]['normalization'][] = compact('normalizer', 'method', 'time', 'name'); } - public function collectEncoding(string $traceId, string $encoder, float $time): void + public function collectEncoding(string $traceId, string $encoder, float $time, string $name): void { $method = 'encode'; - $this->collected[$traceId]['encoding'][] = compact('encoder', 'method', 'time'); + $this->collected[$traceId]['encoding'][] = compact('encoder', 'method', 'time', 'name'); } - public function collectDecoding(string $traceId, string $encoder, float $time): void + public function collectDecoding(string $traceId, string $encoder, float $time, string $name): void { $method = 'decode'; - $this->collected[$traceId]['encoding'][] = compact('encoder', 'method', 'time'); + $this->collected[$traceId]['encoding'][] = compact('encoder', 'method', 'time', 'name'); } public function lateCollect(): void { - $this->data = [ - 'serialize' => [], - 'deserialize' => [], - 'normalize' => [], - 'denormalize' => [], - 'encode' => [], - 'decode' => [], - ]; + $this->data = self::DATA_TEMPLATE; foreach ($this->collected as $collected) { if (!isset($collected['data'])) { @@ -184,6 +193,7 @@ public function lateCollect(): void 'normalization' => [], 'encoding' => [], 'caller' => $collected['caller'] ?? null, + 'name' => $collected['name'], ]; if (isset($collected['normalization'])) { @@ -220,6 +230,22 @@ public function lateCollect(): void } } + 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); diff --git a/Debug/TraceableEncoder.php b/Debug/TraceableEncoder.php index 0795d14ca..39e75e34f 100644 --- a/Debug/TraceableEncoder.php +++ b/Debug/TraceableEncoder.php @@ -23,20 +23,21 @@ * * @author Mathias Arlaud * - * @internal + * @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)); + throw new \BadMethodCallException(\sprintf('The "%s()" method cannot be called as nested encoder doesn\'t implements "%s".', __METHOD__, EncoderInterface::class)); } $startTime = microtime(true); @@ -44,7 +45,7 @@ public function encode(mixed $data, string $format, array $context = []): string $time = microtime(true) - $startTime; if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { - $this->dataCollector->collectEncoding($traceId, $this->encoder::class, $time); + $this->dataCollector->collectEncoding($traceId, $this->encoder::class, $time, $this->serializerName); } return $encoded; @@ -62,7 +63,7 @@ public function supportsEncoding(string $format, array $context = []): bool 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)); + throw new \BadMethodCallException(\sprintf('The "%s()" method cannot be called as nested encoder doesn\'t implements "%s".', __METHOD__, DecoderInterface::class)); } $startTime = microtime(true); @@ -70,7 +71,7 @@ public function decode(string $data, string $format, array $context = []): mixed $time = microtime(true) - $startTime; if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { - $this->dataCollector->collectDecoding($traceId, $this->encoder::class, $time); + $this->dataCollector->collectDecoding($traceId, $this->encoder::class, $time, $this->serializerName); } return $encoded; diff --git a/Debug/TraceableNormalizer.php b/Debug/TraceableNormalizer.php index 50842a7b9..1b143e295 100644 --- a/Debug/TraceableNormalizer.php +++ b/Debug/TraceableNormalizer.php @@ -24,13 +24,14 @@ * * @author Mathias Arlaud * - * @internal + * @final */ class TraceableNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, NormalizerAwareInterface, DenormalizerAwareInterface { public function __construct( private NormalizerInterface|DenormalizerInterface $normalizer, private SerializerDataCollector $dataCollector, + private readonly string $serializerName = 'default', ) { } @@ -42,7 +43,7 @@ public function getSupportedTypes(?string $format): array 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)); + throw new \BadMethodCallException(\sprintf('The "%s()" method cannot be called as nested normalizer doesn\'t implements "%s".', __METHOD__, NormalizerInterface::class)); } $startTime = microtime(true); @@ -50,7 +51,7 @@ public function normalize(mixed $object, ?string $format = null, array $context $time = microtime(true) - $startTime; if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { - $this->dataCollector->collectNormalization($traceId, $this->normalizer::class, $time); + $this->dataCollector->collectNormalization($traceId, $this->normalizer::class, $time, $this->serializerName); } return $normalized; @@ -68,7 +69,7 @@ public function supportsNormalization(mixed $data, ?string $format = null, array 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)); + throw new \BadMethodCallException(\sprintf('The "%s()" method cannot be called as nested normalizer doesn\'t implements "%s".', __METHOD__, DenormalizerInterface::class)); } $startTime = microtime(true); @@ -76,7 +77,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $time = microtime(true) - $startTime; if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { - $this->dataCollector->collectDenormalization($traceId, $this->normalizer::class, $time); + $this->dataCollector->collectDenormalization($traceId, $this->normalizer::class, $time, $this->serializerName); } return $denormalized; diff --git a/Debug/TraceableSerializer.php b/Debug/TraceableSerializer.php index 9cf7bad80..a05bf4bf8 100644 --- a/Debug/TraceableSerializer.php +++ b/Debug/TraceableSerializer.php @@ -23,7 +23,7 @@ * * @author Mathias Arlaud * - * @internal + * @final */ class TraceableSerializer implements SerializerInterface, NormalizerInterface, DenormalizerInterface, EncoderInterface, DecoderInterface { @@ -32,12 +32,13 @@ class TraceableSerializer implements SerializerInterface, NormalizerInterface, D 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 = uniqid('', true); + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); $startTime = microtime(true); $result = $this->serializer->serialize($data, $format, $context); @@ -45,14 +46,14 @@ public function serialize(mixed $data, string $format, array $context = []): str $caller = $this->getCaller(__FUNCTION__, SerializerInterface::class); - $this->dataCollector->collectSerialize($traceId, $data, $format, $context, $time, $caller); + $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 = uniqid('', true); + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); $startTime = microtime(true); $result = $this->serializer->deserialize($data, $type, $format, $context); @@ -60,14 +61,14 @@ public function deserialize(mixed $data, string $type, string $format, array $co $caller = $this->getCaller(__FUNCTION__, SerializerInterface::class); - $this->dataCollector->collectDeserialize($traceId, $data, $type, $format, $context, $time, $caller); + $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 = uniqid('', true); + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); $startTime = microtime(true); $result = $this->serializer->normalize($object, $format, $context); @@ -75,14 +76,14 @@ public function normalize(mixed $object, ?string $format = null, array $context $caller = $this->getCaller(__FUNCTION__, NormalizerInterface::class); - $this->dataCollector->collectNormalize($traceId, $object, $format, $context, $time, $caller); + $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 = uniqid('', true); + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); $startTime = microtime(true); $result = $this->serializer->denormalize($data, $type, $format, $context); @@ -90,14 +91,14 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $caller = $this->getCaller(__FUNCTION__, DenormalizerInterface::class); - $this->dataCollector->collectDenormalize($traceId, $data, $type, $format, $context, $time, $caller); + $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 = uniqid('', true); + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); $startTime = microtime(true); $result = $this->serializer->encode($data, $format, $context); @@ -105,14 +106,14 @@ public function encode(mixed $data, string $format, array $context = []): string $caller = $this->getCaller(__FUNCTION__, EncoderInterface::class); - $this->dataCollector->collectEncode($traceId, $data, $format, $context, $time, $caller); + $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 = uniqid('', true); + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); $startTime = microtime(true); $result = $this->serializer->decode($data, $format, $context); @@ -120,7 +121,7 @@ public function decode(string $data, string $format, array $context = []): mixed $caller = $this->getCaller(__FUNCTION__, DecoderInterface::class); - $this->dataCollector->collectDecode($traceId, $data, $format, $context, $time, $caller); + $this->dataCollector->collectDecode($traceId, $data, $format, $context, $time, $caller, $this->serializerName); return $result; } diff --git a/DependencyInjection/SerializerPass.php b/DependencyInjection/SerializerPass.php index 2a429054b..179b7a3d9 100644 --- a/DependencyInjection/SerializerPass.php +++ b/DependencyInjection/SerializerPass.php @@ -19,6 +19,8 @@ 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 @@ -31,44 +33,201 @@ class SerializerPass implements CompilerPassInterface { use PriorityTaggedServiceTrait; + private const NAME_CONVERTER_METADATA_AWARE_ID = 'serializer.name_converter.metadata_aware'; + public function process(ContainerBuilder $container): void { if (!$container->hasDefinition('serializer')) { return; } - if (!$normalizers = $this->findAndSortTaggedServices('serializer.normalizer', $container)) { + $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', $container)) { + 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.'); } + $defaultContext = []; if ($container->hasParameter('serializer.default_context')) { $defaultContext = $container->getParameter('serializer.default_context'); - foreach (array_merge($normalizers, $encoders) as $service) { - $definition = $container->getDefinition($service); - $definition->setBindings(['array $defaultContext' => new BoundArgument($defaultContext, false)] + $definition->getBindings()); + $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); + } + } + + private function createNamedSerializerTags(ContainerBuilder $container, string $tagName, string $configName, array $namedSerializers): void + { + $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); + } } + } + } - $container->getParameterBag()->remove('serializer.default_context'); + 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()); } + } + 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')]); + ->setArguments([$normalizer, new Reference('serializer.data_collector'), $serializerName]); } foreach ($encoders as $i => $encoder) { $encoders[$i] = $container->register('.debug.serializer.encoder.'.$encoder, TraceableEncoder::class) - ->setArguments([$encoder, new Reference('serializer.data_collector')]); + ->setArguments([$encoder, new Reference('serializer.data_collector'), $serializerName]); } } - $serializerDefinition = $container->getDefinition('serializer'); + $serializerDefinition = $container->getDefinition($id); $serializerDefinition->replaceArgument(0, $normalizers); $serializerDefinition->replaceArgument(1, $encoders); } + + 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); + } + + 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; + } + } + + return null; + } } diff --git a/Encoder/ChainDecoder.php b/Encoder/ChainDecoder.php index f1b0cd2e2..8df5cd8b8 100644 --- a/Encoder/ChainDecoder.php +++ b/Encoder/ChainDecoder.php @@ -33,7 +33,7 @@ class ChainDecoder implements ContextAwareDecoderInterface * @param array $decoders */ public function __construct( - private readonly array $decoders = [] + private readonly array $decoders = [], ) { } @@ -78,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 731cfc601..3621471b4 100644 --- a/Encoder/ChainEncoder.php +++ b/Encoder/ChainEncoder.php @@ -34,7 +34,7 @@ class ChainEncoder implements ContextAwareEncoderInterface * @param array $encoders */ public function __construct( - private readonly array $encoders = [] + private readonly array $encoders = [], ) { } @@ -101,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/CsvEncoder.php b/Encoder/CsvEncoder.php index 8f9c3cff0..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'; @@ -53,6 +56,10 @@ class CsvEncoder implements EncoderInterface, DecoderInterface public function __construct(array $defaultContext = []) { + 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); } @@ -62,7 +69,7 @@ public function encode(mixed $data, string $format, array $context = []): string if (!is_iterable($data)) { $data = [[$data]]; - } elseif (empty($data)) { + } elseif (!$data) { $data = [[]]; } else { // Sequential arrays of arrays are considered as collections @@ -197,7 +204,7 @@ public function decode(string $data, string $format, array $context = []): mixed return $result; } - if (empty($result) || isset($result[1])) { + if (!$result || isset($result[1])) { return $result; } @@ -241,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/JsonDecode.php b/Encoder/JsonDecode.php index 0dbc9f6df..b50538175 100644 --- a/Encoder/JsonDecode.php +++ b/Encoder/JsonDecode.php @@ -106,7 +106,7 @@ public function decode(string $data, string $format, array $context = []): mixed } 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 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); diff --git a/Encoder/XmlEncoder.php b/Encoder/XmlEncoder.php index dc28771b8..e1a816380 100644 --- a/Encoder/XmlEncoder.php +++ b/Encoder/XmlEncoder.php @@ -59,6 +59,7 @@ class XmlEncoder implements EncoderInterface, DecoderInterface, NormalizationAwa 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 array $defaultContext = [ self::AS_COLLECTION => false, @@ -70,6 +71,7 @@ class XmlEncoder implements EncoderInterface, DecoderInterface, NormalizationAwa self::ROOT_NODE_NAME => 'response', self::TYPE_CAST_ATTRIBUTES => true, self::CDATA_WRAPPING => true, + self::CDATA_WRAPPING_PATTERN => '/[<>&]/', ]; public function __construct(array $defaultContext = []) @@ -82,7 +84,7 @@ 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]; @@ -97,7 +99,7 @@ public function encode(mixed $data, string $format, array $context = []): string $this->appendNode($dom, $data, $format, $context, $xmlRootNodeName); } - return $dom->saveXML($ignorePiNode ? $dom->documentElement : null, $context[self::SAVE_OPTIONS] ?? $this->defaultContext[self::SAVE_OPTIONS]); + return $this->saveXml($dom, $ignorePiNode ? $dom->documentElement : null, $context[self::SAVE_OPTIONS] ?? $this->defaultContext[self::SAVE_OPTIONS]); } public function decode(string $data, string $format, array $context = []): mixed @@ -150,9 +152,8 @@ public function decode(string $data, string $format, array $context = []): mixed } $data = array_merge($this->parseXmlAttributes($rootNode, $context), ['#' => $rootNode->nodeValue]); - $data = $this->addXmlNamespaces($data, $rootNode, $dom); - return $data; + return $this->addXmlNamespaces($data, $rootNode, $dom); } public function supportsEncoding(string $format): bool @@ -196,13 +197,9 @@ final protected function appendCData(\DOMNode $node, string $val): bool final protected function appendDocumentFragment(\DOMNode $node, \DOMDocumentFragment $fragment): bool { - if ($fragment instanceof \DOMDocumentFragment) { - $node->appendChild($fragment); - - return true; - } + $node->appendChild($fragment); - return false; + return true; } final protected function appendComment(\DOMNode $node, string $data): bool @@ -391,7 +388,7 @@ private function buildXml(\DOMNode $parentNode, mixed $data, string $format, arr 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); @@ -410,7 +407,7 @@ private function buildXml(\DOMNode $parentNode, mixed $data, string $format, arr 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)))); } /** @@ -437,7 +434,7 @@ private function appendNode(\DOMNode $parentNode, mixed $data, string $format, a */ private function needsCdataWrapping(string $val, array $context): bool { - return ($context[self::CDATA_WRAPPING] ?? $this->defaultContext[self::CDATA_WRAPPING]) && 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); } /** @@ -459,7 +456,7 @@ private function selectNodeType(\DOMNode $node, mixed $val, string $format, arra $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); @@ -502,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 223cd7933..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; @@ -85,7 +87,11 @@ 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); + } } public function supportsDecoding(string $format): bool diff --git a/Exception/ExtraAttributesException.php b/Exception/ExtraAttributesException.php index 24b031ae3..639808b10 100644 --- a/Exception/ExtraAttributesException.php +++ b/Exception/ExtraAttributesException.php @@ -22,7 +22,7 @@ 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'); + $msg = \sprintf('Extra attributes are not allowed ("%s" %s unknown).', implode('", "', $extraAttributes), \count($extraAttributes) > 1 ? 'are' : 'is'); parent::__construct($msg, 0, $previous); } diff --git a/Exception/NotNormalizableValueException.php b/Exception/NotNormalizableValueException.php index 279be4127..4b956f1b0 100644 --- a/Exception/NotNormalizableValueException.php +++ b/Exception/NotNormalizableValueException.php @@ -22,17 +22,17 @@ class NotNormalizableValueException extends UnexpectedValueException private bool $useMessageForUser = false; /** - * @param string[] $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. + * @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, 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/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/Extractor/ObjectPropertyListExtractor.php b/Extractor/ObjectPropertyListExtractor.php index 8422b0786..42a546805 100644 --- a/Extractor/ObjectPropertyListExtractor.php +++ b/Extractor/ObjectPropertyListExtractor.php @@ -18,12 +18,12 @@ */ final class ObjectPropertyListExtractor implements ObjectPropertyListExtractorInterface { - private PropertyListExtractorInterface $propertyListExtractor; private \Closure $objectClassResolver; - public function __construct(PropertyListExtractorInterface $propertyListExtractor, ?callable $objectClassResolver = null) - { - $this->propertyListExtractor = $propertyListExtractor; + public function __construct( + private PropertyListExtractorInterface $propertyListExtractor, + ?callable $objectClassResolver = null, + ) { $this->objectClassResolver = ($objectClassResolver ?? 'get_class')(...); } diff --git a/Mapping/AttributeMetadata.php b/Mapping/AttributeMetadata.php index 743fee412..12d1cf6bf 100644 --- a/Mapping/AttributeMetadata.php +++ b/Mapping/AttributeMetadata.php @@ -90,7 +90,7 @@ public function getName(): string public function addGroup(string $group): void { - if (!\in_array($group, $this->groups)) { + if (!\in_array($group, $this->groups, true)) { $this->groups[] = $group; } } diff --git a/Mapping/ClassMetadata.php b/Mapping/ClassMetadata.php index e68f4c71f..b635ee10b 100644 --- a/Mapping/ClassMetadata.php +++ b/Mapping/ClassMetadata.php @@ -78,11 +78,7 @@ public function merge(ClassMetadataInterface $classMetadata): void public function getReflectionClass(): \ReflectionClass { - if (!$this->reflClass) { - $this->reflClass = new \ReflectionClass($this->getName()); - } - - return $this->reflClass; + return $this->reflClass ??= new \ReflectionClass($this->getName()); } public function getClassDiscriminatorMapping(): ?ClassDiscriminatorMapping diff --git a/Mapping/Factory/ClassMetadataFactoryCompiler.php b/Mapping/Factory/ClassMetadataFactoryCompiler.php index f01fe9ce2..1e9202b7d 100644 --- a/Mapping/Factory/ClassMetadataFactoryCompiler.php +++ b/Mapping/Factory/ClassMetadataFactoryCompiler.php @@ -57,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/ClassResolverTrait.php b/Mapping/Factory/ClassResolverTrait.php index 7af722d55..d7037f894 100644 --- a/Mapping/Factory/ClassResolverTrait.php +++ b/Mapping/Factory/ClassResolverTrait.php @@ -31,7 +31,7 @@ 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, '\\'); diff --git a/Mapping/Factory/CompiledClassMetadataFactory.php b/Mapping/Factory/CompiledClassMetadataFactory.php index f4d41c1e6..ec25d7440 100644 --- a/Mapping/Factory/CompiledClassMetadataFactory.php +++ b/Mapping/Factory/CompiledClassMetadataFactory.php @@ -35,7 +35,7 @@ public function __construct( $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; diff --git a/Mapping/Loader/AttributeLoader.php b/Mapping/Loader/AttributeLoader.php index ddb467164..13c59d1c3 100644 --- a/Mapping/Loader/AttributeLoader.php +++ b/Mapping/Loader/AttributeLoader.php @@ -53,28 +53,17 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $className = $reflectionClass->name; $loaded = false; $classGroups = []; - $classContextAnnotation = null; + $classContextAttribute = null; $attributesMetadata = $classMetadata->getAttributesMetadata(); - foreach ($this->loadAttributes($reflectionClass) as $annotation) { - if ($annotation instanceof DiscriminatorMap) { - $classMetadata->setClassDiscriminatorMapping(new ClassDiscriminatorMapping( - $annotation->getTypeProperty(), - $annotation->getMapping() - )); - continue; - } - - if ($annotation instanceof Groups) { - $classGroups = $annotation->getGroups(); - - continue; - } - - if ($annotation instanceof Context) { - $classContextAnnotation = $annotation; - } + 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) { @@ -83,33 +72,35 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $classMetadata->addAttributeMetadata($attributesMetadata[$property->name]); } + $attributeMetadata = $attributesMetadata[$property->name]; if ($property->getDeclaringClass()->name === $className) { - if ($classContextAnnotation) { - $this->setAttributeContextsForGroups($classContextAnnotation, $attributesMetadata[$property->name]); + if ($classContextAttribute) { + $this->setAttributeContextsForGroups($classContextAttribute, $attributeMetadata); } foreach ($classGroups as $group) { - $attributesMetadata[$property->name]->addGroup($group); + $attributeMetadata->addGroup($group); } - foreach ($this->loadAttributes($property) as $annotation) { - if ($annotation instanceof Groups) { - foreach ($annotation->getGroups() as $group) { - $attributesMetadata[$property->name]->addGroup($group); + foreach ($this->loadAttributes($property) as $attribute) { + $loaded = true; + + if ($attribute instanceof Groups) { + foreach ($attribute->getGroups() as $group) { + $attributeMetadata->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 SerializedPath) { - $attributesMetadata[$property->name]->setSerializedPath($annotation->getSerializedPath()); - } elseif ($annotation instanceof Ignore) { - $attributesMetadata[$property->name]->setIgnore(true); - } elseif ($annotation instanceof Context) { - $this->setAttributeContextsForGroups($annotation, $attributesMetadata[$property->name]); + + continue; } - $loaded = true; + 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, + }; } } } @@ -124,7 +115,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool } $accessorOrMutator = preg_match('/^(get|is|has|set)(.+)$/i', $method->name, $matches); - if ($accessorOrMutator) { + if ($accessorOrMutator && !ctype_lower($matches[2][0])) { $attributeName = lcfirst($matches[2]); if (isset($attributesMetadata[$attributeName])) { @@ -135,43 +126,43 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool } } - foreach ($this->loadAttributes($method) as $annotation) { - if ($annotation instanceof Groups) { + 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)); + 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) { + foreach ($attribute->getGroups() as $group) { $attributeMetadata->addGroup($group); } - } elseif ($annotation instanceof MaxDepth) { + } 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)); + 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) { + $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)); + 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 SerializedPath) { + $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)); + 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($annotation->getSerializedPath()); - } elseif ($annotation instanceof Ignore) { + $attributeMetadata->setSerializedPath($attribute->getSerializedPath()); + } elseif ($attribute instanceof Ignore) { if ($accessorOrMutator) { $attributeMetadata->setIgnore(true); } - } elseif ($annotation instanceof Context) { + } 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)); + 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); + $this->setAttributeContextsForGroups($attribute, $attributeMetadata); } $loaded = true; @@ -193,30 +184,30 @@ private function loadAttributes(\ReflectionMethod|\ReflectionClass|\ReflectionPr } $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), + $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); + throw new MappingException(\sprintf('Could not instantiate attribute "%s"%s.', $attribute->getName(), $on), 0, $e); } } } } - private function setAttributeContextsForGroups(Context $annotation, AttributeMetadataInterface $attributeMetadata): void + private function setAttributeContextsForGroups(Context $attribute, AttributeMetadataInterface $attributeMetadata): void { - if ($annotation->getContext()) { - $attributeMetadata->setNormalizationContextForGroups($annotation->getContext(), $annotation->getGroups()); - $attributeMetadata->setDenormalizationContextForGroups($annotation->getContext(), $annotation->getGroups()); - } + $context = $attribute->getContext(); + $groups = $attribute->getGroups(); + $normalizationContext = $attribute->getNormalizationContext(); + $denormalizationContext = $attribute->getDenormalizationContext(); - if ($annotation->getNormalizationContext()) { - $attributeMetadata->setNormalizationContextForGroups($annotation->getNormalizationContext(), $annotation->getGroups()); + if ($normalizationContext || $context) { + $attributeMetadata->setNormalizationContextForGroups($normalizationContext ?: $context, $groups); } - if ($annotation->getDenormalizationContext()) { - $attributeMetadata->setDenormalizationContextForGroups($annotation->getDenormalizationContext(), $annotation->getGroups()); + if ($denormalizationContext || $context) { + $attributeMetadata->setDenormalizationContextForGroups($denormalizationContext ?: $context, $groups); } } diff --git a/Mapping/Loader/FileLoader.php b/Mapping/Loader/FileLoader.php index 7fda4ebd5..ac68d21e8 100644 --- a/Mapping/Loader/FileLoader.php +++ b/Mapping/Loader/FileLoader.php @@ -20,23 +20,20 @@ */ abstract class FileLoader implements LoaderInterface { - protected string $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 e93a0a7b0..41abf8cec 100644 --- a/Mapping/Loader/LoaderChain.php +++ b/Mapping/Loader/LoaderChain.php @@ -38,7 +38,7 @@ 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))); } } } diff --git a/Mapping/Loader/XmlFileLoader.php b/Mapping/Loader/XmlFileLoader.php index b36fb871d..44ba89df1 100644 --- a/Mapping/Loader/XmlFileLoader.php +++ b/Mapping/Loader/XmlFileLoader.php @@ -70,7 +70,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool 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())); + 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())); } } diff --git a/Mapping/Loader/YamlFileLoader.php b/Mapping/Loader/YamlFileLoader.php index d47ad7430..ca71cbcba 100644 --- a/Mapping/Loader/YamlFileLoader.php +++ b/Mapping/Loader/YamlFileLoader.php @@ -59,12 +59,12 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool 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); @@ -73,7 +73,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool 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']); @@ -81,7 +81,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool if (isset($data['serialized_name'])) { 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())); + 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']); @@ -91,13 +91,13 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool 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())); + 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']); @@ -124,11 +124,11 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool 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( @@ -153,19 +153,19 @@ public function getMappedClasses(): array 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)); } $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/NameConverter/AdvancedNameConverterInterface.php b/NameConverter/AdvancedNameConverterInterface.php index 1e74f4d20..975d28fd3 100644 --- a/NameConverter/AdvancedNameConverterInterface.php +++ b/NameConverter/AdvancedNameConverterInterface.php @@ -15,6 +15,8 @@ * 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 { diff --git a/NameConverter/CamelCaseToSnakeCaseNameConverter.php b/NameConverter/CamelCaseToSnakeCaseNameConverter.php index ab6f99e13..033ec94b7 100644 --- a/NameConverter/CamelCaseToSnakeCaseNameConverter.php +++ b/NameConverter/CamelCaseToSnakeCaseNameConverter.php @@ -11,16 +11,24 @@ 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 { /** - * @param array|null $attributes The list of attributes to rename or null for all attributes - * @param bool $lowerCamelCase Use lowerCamelCase style + * Require all properties to be written in snake_case. + */ + public const REQUIRE_SNAKE_CASE_PROPERTIES = 'require_snake_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, @@ -28,24 +36,42 @@ public function __construct( ) { } - public function normalize(string $propertyName): string + /** + * @param class-string|null $class + * @param string|null $format + * @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)) { + if (null === $this->attributes || \in_array($propertyName, $this->attributes, true)) { return strtolower(preg_replace('/[A-Z]/', '_\\0', lcfirst($propertyName))); } return $propertyName; } - public function denormalize(string $propertyName): string + /** + * @param class-string|null $class + * @param string|null $format + * @param array $context + */ + public function denormalize(string $propertyName/* , ?string $class = null, ?string $format = null, array $context = [] */): string { + $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 445ad7422..c72f148eb 100644 --- a/NameConverter/MetadataAwareNameConverter.php +++ b/NameConverter/MetadataAwareNameConverter.php @@ -80,7 +80,7 @@ private function getCacheValueForNormalization(string $propertyName, string $cla } 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)); + throw new LogicException(\sprintf('Found SerializedName and SerializedPath attributes on property "%s" of class "%s".', $propertyName, $class)); } return $attributesMetadata[$propertyName]->getSerializedName() ?? null; @@ -124,7 +124,7 @@ private function getCacheValueForAttributesMetadata(string $class, array $contex } if (null !== $metadata->getSerializedName() && null !== $metadata->getSerializedPath()) { - throw new LogicException(sprintf('Found SerializedName and SerializedPath attributes on property "%s" of class "%s".', $name, $class)); + throw new LogicException(\sprintf('Found SerializedName and SerializedPath attributes on property "%s" of class "%s".', $name, $class)); } $metadataGroups = $metadata->getGroups(); diff --git a/NameConverter/NameConverterInterface.php b/NameConverter/NameConverterInterface.php index aba69a49e..d6bfeceb4 100644 --- a/NameConverter/NameConverterInterface.php +++ b/NameConverter/NameConverterInterface.php @@ -20,11 +20,19 @@ interface NameConverterInterface { /** * Converts a property name to its normalized value. + * + * @param class-string|null $class + * @param string|null $format + * @param array $context */ - public function normalize(string $propertyName): string; + public function normalize(string $propertyName/* , ?string $class = null, ?string $format = null, array $context = [] */): string; /** * Converts a property name to its denormalized value. + * + * @param class-string|null $class + * @param string|null $format + * @param array $context */ - public function denormalize(string $propertyName): string; + 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 c5cae6018..04f378c46 100644 --- a/Normalizer/AbstractNormalizer.php +++ b/Normalizer/AbstractNormalizer.php @@ -118,6 +118,16 @@ abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerIn */ 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 */ @@ -129,22 +139,21 @@ abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerIn self::CIRCULAR_REFERENCE_LIMIT => 1, self::IGNORED_ATTRIBUTES => [], ]; - protected ?ClassMetadataFactoryInterface $classMetadataFactory; - protected ?NameConverterInterface $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)); } } @@ -190,7 +199,7 @@ protected function handleCircularReference(object $object, ?string $format = nul 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])); } /** @@ -207,7 +216,7 @@ protected function getAllowedAttributes(string|object $classOrObject, array $con $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; @@ -217,6 +226,7 @@ protected function getAllowedAttributes(string|object $classOrObject, array $con $allowedAttributes = []; $ignoreUsed = false; + foreach ($this->classMetadataFactory->getMetadataFor($classOrObject)->getAttributesMetadata() as $attributeMetadata) { if ($ignore = $attributeMetadata->isIgnored()) { $ignoreUsed = true; @@ -225,7 +235,7 @@ protected function getAllowedAttributes(string|object $classOrObject, array $con // 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)) + && ([] === $groups || \in_array('*', $groups, true) || array_intersect($attributeMetadata->getGroups(), $groups)) && $this->isAllowedAttribute($classOrObject, $name = $attributeMetadata->getName(), null, $context) ) { $allowedAttributes[] = $attributesAsString ? $name : $attributeMetadata; @@ -253,7 +263,7 @@ protected function getGroups(array $context): array 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; } @@ -326,17 +336,26 @@ protected function instantiateObject(array &$data, string $class, array &$contex $attributeContext = $this->getAttributeDenormalizationContext($class, $paramName, $context); $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $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, $attributeContext, $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); @@ -384,7 +403,7 @@ 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], $attributeContext['deserialization_path'] ?? null, @@ -395,7 +414,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex } 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, $class); + 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()) { @@ -430,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(); @@ -452,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; @@ -467,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); } /** @@ -498,12 +514,12 @@ 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" : '')); } } } @@ -518,6 +534,19 @@ final protected function applyCallbacks(mixed $value, object|string $object, 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. * diff --git a/Normalizer/AbstractObjectNormalizer.php b/Normalizer/AbstractObjectNormalizer.php index 88056e677..00d2e3b00 100644 --- a/Normalizer/AbstractObjectNormalizer.php +++ b/Normalizer/AbstractObjectNormalizer.php @@ -12,11 +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; @@ -31,6 +32,16 @@ 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. @@ -50,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. */ @@ -108,7 +119,10 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer protected ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver; - private array $typesCache = []; + /** + * @var array|false> + */ + private array $typeCache = []; private array $attributesCache = []; private readonly \Closure $objectClassResolver; @@ -123,7 +137,7 @@ public function __construct( 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]); @@ -163,7 +177,7 @@ public function normalize(mixed $object, ?string $format = null, array $context 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; @@ -204,7 +218,7 @@ public function normalize(mixed $object, ?string $format = null, array $context } 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)); } $childContext = $this->createChildContext($attributeContext, $attribute, $format); @@ -293,7 +307,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $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; } @@ -330,13 +344,13 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $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)); + 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; } @@ -359,11 +373,15 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a } } - $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; @@ -379,9 +397,9 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $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(), @@ -407,14 +425,17 @@ abstract protected function setAttributeValue(object $object, string $attribute, /** * Validates the submitted data and denormalizes it. * - * @param Type[] $types + * BC layer for PropertyTypeExtractorInterface::getTypes(). + * Can be removed as soon as PropertyTypeExtractorInterface::getTypes() is removed (8.0). + * + * @param LegacyType[] $types * * @throws NotNormalizableValueException * @throws ExtraAttributesException * @throws MissingConstructorArgumentsException * @throws LogicException */ - private function validateAndDenormalize(array $types, string $currentClass, string $attribute, mixed $data, ?string $format, array $context): mixed + private function validateAndDenormalizeLegacy(array $types, string $currentClass, string $attribute, mixed $data, ?string $format, array $context): mixed { $expectedTypes = []; $isUnionType = \count($types) > 1; @@ -447,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 ''; } @@ -460,24 +481,24 @@ 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: + 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; } @@ -486,13 +507,13 @@ private function validateAndDenormalize(array $types, string $currentClass, stri '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::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null), + 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) { @@ -500,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(); } @@ -525,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); @@ -544,25 +565,29 @@ 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; } - if ((Type::BUILTIN_TYPE_FALSE === $builtinType && false === $data) || (Type::BUILTIN_TYPE_TRUE === $builtinType && true === $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); + } + + if ((LegacyType::BUILTIN_TYPE_FALSE === $builtinType && false === $data) || (LegacyType::BUILTIN_TYPE_TRUE === $builtinType && true === $data)) { return $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: + 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; } @@ -608,7 +633,292 @@ 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); } /** @@ -616,52 +926,73 @@ private function validateAndDenormalize(array $types, string $currentClass, stri */ 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); + } + + $parameterData = $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); - return $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 ($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; } + /** + * BC layer for PropertyTypeExtractorInterface::getTypes(). + * Can be removed as soon as PropertyTypeExtractorInterface::getTypes() is removed (8.0). + * + * @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. */ @@ -674,7 +1005,7 @@ private function updateData(array $data, string $attribute, mixed $attributeValu 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)); + throw new LogicException(\sprintf('The element you are trying to set is already populated: "%s".', (string) $serializedPath)); } $propertyAccessor->setValue($data, $serializedPath, $attributeValue); @@ -703,7 +1034,7 @@ private function isMaxDepthReached(array $attributesMetadata, string $class, str 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; @@ -792,7 +1123,7 @@ private function getNestedAttributes(string $class): array } $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)); + throw new LogicException(\sprintf('Duplicate serialized path: "%s" used for properties "%s" and "%s".', $pathIdentifier, $serializedPaths[$pathIdentifier], $name)); } $serializedPaths[$pathIdentifier] = $name; $properties[$name] = $serializedPath; @@ -825,11 +1156,11 @@ private function getMappedClass(array $data, string $class, array $context): str } 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); + 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); + 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 a52c0de46..96c4d259c 100644 --- a/Normalizer/ArrayDenormalizer.php +++ b/Normalizer/ArrayDenormalizer.php @@ -11,10 +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\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Denormalizes arrays of objects. @@ -27,11 +31,6 @@ class ArrayDenormalizer implements DenormalizerInterface, DenormalizerAwareInter { use DenormalizerAwareTrait; - public function setDenormalizer(DenormalizerInterface $denormalizer): void - { - $this->denormalizer = $denormalizer; - } - public function getSupportedTypes(?string $format): array { return ['object' => null, '*' => false]; @@ -46,7 +45,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a 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); @@ -54,15 +53,28 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $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); } @@ -73,25 +85,28 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { if (!isset($this->denormalizer)) { - throw new BadMethodCallException(sprintf('The nested denormalizer needs to be set to allow "%s()" to be used.', __METHOD__)); + throw new BadMethodCallException(\sprintf('The nested denormalizer needs to be set to allow "%s()" to be used.', __METHOD__)); } return str_ends_with($type, '[]') && $this->denormalizer->supportsDenormalization($data, substr($type, 0, -2), $format, $context); } - private function validateKeyType(array $builtinTypes, mixed $key, string $path): void + /** + * @param list $typeIdentifiers + */ + 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 f6cefed87..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; @@ -30,7 +29,7 @@ final class BackedEnumNormalizer implements NormalizerInterface, DenormalizerInt public function getSupportedTypes(?string $format): array { return [ - \BackedEnum::class => true, + \BackedEnum::class => true, ]; } @@ -70,7 +69,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a } 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 { diff --git a/Normalizer/ConstraintViolationListNormalizer.php b/Normalizer/ConstraintViolationListNormalizer.php index eeb6ab462..eda3b758e 100644 --- a/Normalizer/ConstraintViolationListNormalizer.php +++ b/Normalizer/ConstraintViolationListNormalizer.php @@ -69,7 +69,7 @@ public function normalize(mixed $object, ?string $format = null, array $context '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(); @@ -85,7 +85,7 @@ public function normalize(mixed $object, ?string $format = null, array $context $violations[] = $violationEntry; - $prefix = $propertyPath ? sprintf('%s: ', $propertyPath) : ''; + $prefix = $propertyPath ? \sprintf('%s: ', $propertyPath) : ''; $messages[] = $prefix.$violation->getMessage(); } diff --git a/Normalizer/DataUriNormalizer.php b/Normalizer/DataUriNormalizer.php index 7f51316b9..5ee076be6 100644 --- a/Normalizer/DataUriNormalizer.php +++ b/Normalizer/DataUriNormalizer.php @@ -44,11 +44,7 @@ public function __construct(?MimeTypeGuesserInterface $mimeTypeGuesser = null) public function getSupportedTypes(?string $format): array { - return [ - \SplFileInfo::class => true, - \SplFileObject::class => true, - File::class => true, - ]; + return self::SUPPORTED_TYPES; } public function normalize(mixed $object, ?string $format = null, array $context = []): string @@ -68,10 +64,10 @@ public function normalize(mixed $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)); } public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool @@ -89,7 +85,7 @@ public function supportsNormalization(mixed $data, ?string $format = null, array */ 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); } @@ -97,7 +93,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a 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); @@ -110,7 +106,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a 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)); } public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool diff --git a/Normalizer/DateIntervalNormalizer.php b/Normalizer/DateIntervalNormalizer.php index 995d4f6a3..05d1a8529 100644 --- a/Normalizer/DateIntervalNormalizer.php +++ b/Normalizer/DateIntervalNormalizer.php @@ -85,7 +85,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a } $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 { diff --git a/Normalizer/DateTimeNormalizer.php b/Normalizer/DateTimeNormalizer.php index 4e36aa63b..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; @@ -25,10 +24,12 @@ final class DateTimeNormalizer implements NormalizerInterface, DenormalizerInter { public const FORMAT_KEY = 'datetime_format'; public const TIMEZONE_KEY = 'datetime_timezone'; + public const CAST_KEY = 'datetime_cast'; private array $defaultContext = [ self::FORMAT_KEY => \DateTimeInterface::RFC3339, self::TIMEZONE_KEY => null, + self::CAST_KEY => null, ]; private const SUPPORTED_TYPES = [ @@ -49,17 +50,13 @@ public function setDefaultContext(array $defaultContext): void public function getSupportedTypes(?string $format): array { - return [ - \DateTimeInterface::class => true, - \DateTimeImmutable::class => true, - \DateTime::class => true, - ]; + return self::SUPPORTED_TYPES; } /** * @throws InvalidArgumentException */ - public function normalize(mixed $object, ?string $format = null, array $context = []): string + 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".'); @@ -73,7 +70,11 @@ public function normalize(mixed $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), + }; } public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool @@ -88,13 +89,17 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a { 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 { @@ -112,7 +117,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $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; @@ -127,13 +132,13 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a } 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); } } public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { - return isset(self::SUPPORTED_TYPES[$type]); + return is_a($type, \DateTimeInterface::class, true); } /** @@ -146,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 437e40bfc..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; @@ -52,13 +51,13 @@ public function supportsNormalization(mixed $data, ?string $format = null, array 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); } } diff --git a/Normalizer/GetSetMethodNormalizer.php b/Normalizer/GetSetMethodNormalizer.php index 3a398ef33..4d2d7829a 100644 --- a/Normalizer/GetSetMethodNormalizer.php +++ b/Normalizer/GetSetMethodNormalizer.php @@ -87,8 +87,8 @@ private function isGetMethod(\ReflectionMethod $method): bool return !$method->isStatic() && !($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])) ); } @@ -100,7 +100,10 @@ private function isSetMethod(\ReflectionMethod $method): bool return !$method->isStatic() && !$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]) + ; } protected function extractAttributes(object $object, ?string $format = null, array $context = []): array @@ -164,7 +167,7 @@ protected function isAllowedAttribute($classOrObject, string $attribute, ?string 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 1287627fb..31c224175 100644 --- a/Normalizer/JsonSerializableNormalizer.php +++ b/Normalizer/JsonSerializableNormalizer.php @@ -28,7 +28,7 @@ public function normalize(mixed $object, ?string $format = null, array $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) { @@ -57,6 +57,6 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { - throw new LogicException(sprintf('Cannot denormalize with "%s".', \JsonSerializable::class)); + throw new LogicException(\sprintf('Cannot denormalize with "%s".', \JsonSerializable::class)); } } diff --git a/Normalizer/MimeMessageNormalizer.php b/Normalizer/MimeMessageNormalizer.php index 7006ab321..633edf369 100644 --- a/Normalizer/MimeMessageNormalizer.php +++ b/Normalizer/MimeMessageNormalizer.php @@ -56,7 +56,7 @@ public function getSupportedTypes(?string $format): array 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))); + 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); diff --git a/Normalizer/NormalizerInterface.php b/Normalizer/NormalizerInterface.php index 562f87c28..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,7 +36,7 @@ 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(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null; + 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. diff --git a/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index 1f19bd9d8..1d60cba50 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -88,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') || str_starts_with($name, 'can')) { + // 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); @@ -173,11 +179,11 @@ protected function isAllowedAttribute($classOrObject, string $attribute, ?string return false; } - $class = \is_object($classOrObject) ? \get_class($classOrObject) : $classOrObject; + $class = \is_object($classOrObject) ? $classOrObject::class : $classOrObject; if ($context['_read_attributes'] ?? true) { if (!isset(self::$isReadableCache[$class.$attribute])) { - self::$isReadableCache[$class.$attribute] = (\is_object($classOrObject) && $this->propertyAccessor->isReadable($classOrObject, $attribute)) || $this->propertyInfoExtractor->isReadable($class, $attribute) || $this->hasAttributeAccessorMethod($class, $attribute); + self::$isReadableCache[$class.$attribute] = $this->propertyInfoExtractor->isReadable($class, $attribute) || $this->hasAttributeAccessorMethod($class, $attribute) || (\is_object($classOrObject) && $this->propertyAccessor->isReadable($classOrObject, $attribute)); } return self::$isReadableCache[$class.$attribute]; diff --git a/Normalizer/ProblemNormalizer.php b/Normalizer/ProblemNormalizer.php index 04c680647..08aca6796 100644 --- a/Normalizer/ProblemNormalizer.php +++ b/Normalizer/ProblemNormalizer.php @@ -54,7 +54,7 @@ public function getSupportedTypes(?string $format): array 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 = []; diff --git a/Normalizer/PropertyNormalizer.php b/Normalizer/PropertyNormalizer.php index e1d893be8..1619f35bf 100644 --- a/Normalizer/PropertyNormalizer.php +++ b/Normalizer/PropertyNormalizer.php @@ -162,7 +162,7 @@ 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.', $object::class, $reflectionProperty->name)); + throw new UninitializedPropertyException(\sprintf('The property "%s::$%s" is not initialized.', $object::class, $reflectionProperty->name)); } } diff --git a/Normalizer/TranslatableNormalizer.php b/Normalizer/TranslatableNormalizer.php index 398e00b79..463616e72 100644 --- a/Normalizer/TranslatableNormalizer.php +++ b/Normalizer/TranslatableNormalizer.php @@ -37,7 +37,7 @@ public function __construct( 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]); + 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]); diff --git a/Normalizer/UidNormalizer.php b/Normalizer/UidNormalizer.php index 732f802be..0bee1f704 100644 --- a/Normalizer/UidNormalizer.php +++ b/Normalizer/UidNormalizer.php @@ -11,7 +11,6 @@ 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; @@ -24,6 +23,8 @@ final class UidNormalizer implements NormalizerInterface, DenormalizerInterface public const NORMALIZATION_FORMAT_BASE58 = 'base58'; public const NORMALIZATION_FORMAT_BASE32 = 'base32'; 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 + public const NORMALIZATION_FORMATS = [ self::NORMALIZATION_FORMAT_CANONICAL, self::NORMALIZATION_FORMAT_BASE58, @@ -57,7 +58,7 @@ public function normalize(mixed $object, ?string $format = null, array $context 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])), + default => throw new LogicException(\sprintf('The "%s" format is not valid.', $context[self::NORMALIZATION_FORMAT_KEY] ?? $this->defaultContext[self::NORMALIZATION_FORMAT_KEY])), }; } @@ -71,7 +72,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a try { return $type::fromString($data); } catch (\InvalidArgumentException|\TypeError) { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The data is not a valid "%s" string representation.', $type), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true); + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The data is not a valid "%s" string representation.', $type), $data, ['string'], $context['deserialization_path'] ?? null, true); } } diff --git a/Serializer.php b/Serializer.php index 9fba20f14..f95f2d72e 100644 --- a/Serializer.php +++ b/Serializer.php @@ -75,10 +75,12 @@ class Serializer implements SerializerInterface, NormalizerInterface, Denormaliz /** * @param array $normalizers * @param array $encoders + * @param array $defaultContext */ public function __construct( private array $normalizers = [], array $encoders = [], + private array $defaultContext = [], ) { foreach ($normalizers as $normalizer) { if ($normalizer instanceof SerializerAwareInterface) { @@ -94,7 +96,7 @@ public function __construct( } 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)); } } @@ -112,7 +114,7 @@ public function __construct( } 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); @@ -122,7 +124,7 @@ public function __construct( final public function serialize(mixed $data, string $format, array $context = []): string { if (!$this->supportsEncoding($format, $context)) { - throw new UnsupportedFormatException(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)) { @@ -135,7 +137,7 @@ final public function serialize(mixed $data, string $format, array $context = [] final public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed { if (!$this->supportsDecoding($format, $context)) { - throw new UnsupportedFormatException(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); @@ -154,12 +156,12 @@ public function normalize(mixed $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(); } @@ -176,10 +178,10 @@ public function normalize(mixed $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)))); } /** @@ -197,7 +199,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a // 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; @@ -208,10 +210,10 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a } 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']; diff --git a/Tests/Annotation/ContextTest.php b/Tests/Attribute/ContextTest.php similarity index 94% rename from Tests/Annotation/ContextTest.php rename to Tests/Attribute/ContextTest.php index 9584d6f1b..cfe175050 100644 --- a/Tests/Annotation/ContextTest.php +++ b/Tests/Attribute/ContextTest.php @@ -9,7 +9,7 @@ * 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\Attribute\Context; @@ -40,7 +40,7 @@ public function testThrowsOnEmptyContext() 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)); + $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()]); } diff --git a/Tests/Annotation/DiscriminatorMapTest.php b/Tests/Attribute/DiscriminatorMapTest.php similarity index 82% rename from Tests/Annotation/DiscriminatorMapTest.php rename to Tests/Attribute/DiscriminatorMapTest.php index bbd112e26..497bc6201 100644 --- a/Tests/Annotation/DiscriminatorMapTest.php +++ b/Tests/Attribute/DiscriminatorMapTest.php @@ -9,7 +9,7 @@ * 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\Attribute\DiscriminatorMap; @@ -22,16 +22,16 @@ class DiscriminatorMapTest extends TestCase { public function testGetTypePropertyAndMapping() { - $annotation = new DiscriminatorMap(typeProperty: 'type', mapping: [ + $attribute = new DiscriminatorMap(typeProperty: 'type', mapping: [ 'foo' => 'FooClass', 'bar' => 'BarClass', ]); - $this->assertEquals('type', $annotation->getTypeProperty()); + $this->assertEquals('type', $attribute->getTypeProperty()); $this->assertEquals([ 'foo' => 'FooClass', 'bar' => 'BarClass', - ], $annotation->getMapping()); + ], $attribute->getMapping()); } public function testExceptionWithEmptyTypeProperty() diff --git a/Tests/Annotation/GroupsTest.php b/Tests/Attribute/GroupsTest.php similarity index 95% rename from Tests/Annotation/GroupsTest.php rename to Tests/Attribute/GroupsTest.php index 38ec518bd..266cbc4f4 100644 --- a/Tests/Annotation/GroupsTest.php +++ b/Tests/Attribute/GroupsTest.php @@ -9,7 +9,7 @@ * 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\Attribute\Groups; diff --git a/Tests/Annotation/MaxDepthTest.php b/Tests/Attribute/MaxDepthTest.php similarity index 94% rename from Tests/Annotation/MaxDepthTest.php rename to Tests/Attribute/MaxDepthTest.php index f2ff35c87..e611bfbc4 100644 --- a/Tests/Annotation/MaxDepthTest.php +++ b/Tests/Attribute/MaxDepthTest.php @@ -9,7 +9,7 @@ * 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\Attribute\MaxDepth; diff --git a/Tests/Annotation/SerializedNameTest.php b/Tests/Attribute/SerializedNameTest.php similarity index 83% rename from Tests/Annotation/SerializedNameTest.php rename to Tests/Attribute/SerializedNameTest.php index c2b5e5f2a..c645e7e85 100644 --- a/Tests/Annotation/SerializedNameTest.php +++ b/Tests/Attribute/SerializedNameTest.php @@ -9,7 +9,7 @@ * 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\Attribute\SerializedName; @@ -30,7 +30,7 @@ public function testNotAStringSerializedNameParameter() public function testSerializedNameParameters() { - $maxDepth = new SerializedName('foo'); - $this->assertEquals('foo', $maxDepth->getSerializedName()); + $foo = new SerializedName('foo'); + $this->assertEquals('foo', $foo->getSerializedName()); } } diff --git a/Tests/Annotation/SerializedPathTest.php b/Tests/Attribute/SerializedPathTest.php similarity index 95% rename from Tests/Annotation/SerializedPathTest.php rename to Tests/Attribute/SerializedPathTest.php index f5bbfa62b..7ba31fc22 100644 --- a/Tests/Annotation/SerializedPathTest.php +++ b/Tests/Attribute/SerializedPathTest.php @@ -9,7 +9,7 @@ * 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\PropertyAccess\PropertyPath; diff --git a/Tests/Command/DebugCommandTest.php b/Tests/Command/DebugCommandTest.php index 879231160..7bfdf93dd 100644 --- a/Tests/Command/DebugCommandTest.php +++ b/Tests/Command/DebugCommandTest.php @@ -36,41 +36,43 @@ public function testOutputWithClassArgument() Symfony\Component\Serializer\Tests\Dummy\DummyClassOne ------------------------------------------------------ - +----------+-------------------------------------+ - | Property | Options | - +----------+-------------------------------------+ - | code | [ | - | | "groups" => [ | - | | "book:read", | - | | "book:write" | - | | ], | - | | "maxDepth" => 1, | - | | "serializedName" => "identifier", | - | | "ignore" => true, | - | | "normalizationContexts" => [ | - | | "*" => [ | - | | "groups" => [ | - | | "book:read" | - | | ] | - | | ] | - | | ], | - | | "denormalizationContexts" => [ | - | | "*" => [ | - | | "groups" => [ | - | | "book:write" | - | | ] | - | | ] | - | | ] | - | | ] | - | name | [ | - | | "groups" => [], | - | | "maxDepth" => null, | - | | "serializedName" => null, | - | | "ignore" => false, | - | | "normalizationContexts" => [], | - | | "denormalizationContexts" => [] | - | | ] | - +----------+-------------------------------------+ + +----------+---------------------------------------+ + | Property | Options | + +----------+---------------------------------------+ + | code | [ | + | | "groups" => [ | + | | "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), diff --git a/Tests/Context/ContextBuilderTraitTest.php b/Tests/Context/ContextBuilderTraitTest.php index 17ad231a0..edd3937e2 100644 --- a/Tests/Context/ContextBuilderTraitTest.php +++ b/Tests/Context/ContextBuilderTraitTest.php @@ -22,7 +22,7 @@ class ContextBuilderTraitTest extends TestCase { public function testWithContext() { - $contextBuilder = new class() implements ContextBuilderInterface { + $contextBuilder = new class implements ContextBuilderInterface { use ContextBuilderTrait; }; @@ -37,7 +37,7 @@ public function testWithContext() public function testWith() { - $contextBuilder = new class() { + $contextBuilder = new class { use ContextBuilderTrait; public function withFoo(string $value): static diff --git a/Tests/Context/Encoder/CsvEncoderContextBuilderTest.php b/Tests/Context/Encoder/CsvEncoderContextBuilderTest.php index c71d41b63..fe39feb81 100644 --- a/Tests/Context/Encoder/CsvEncoderContextBuilderTest.php +++ b/Tests/Context/Encoder/CsvEncoderContextBuilderTest.php @@ -12,6 +12,7 @@ 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; @@ -21,6 +22,8 @@ */ class CsvEncoderContextBuilderTest extends TestCase { + use ExpectUserDeprecationMessageTrait; + private CsvEncoderContextBuilder $contextBuilder; protected function setUp(): void @@ -38,7 +41,6 @@ public function testWithers(array $values) $context = $this->contextBuilder ->withDelimiter($values[CsvEncoder::DELIMITER_KEY]) ->withEnclosure($values[CsvEncoder::ENCLOSURE_KEY]) - ->withEscapeChar($values[CsvEncoder::ESCAPE_CHAR_KEY]) ->withKeySeparator($values[CsvEncoder::KEY_SEPARATOR_KEY]) ->withHeaders($values[CsvEncoder::HEADERS_KEY]) ->withEscapedFormulas($values[CsvEncoder::ESCAPE_FORMULAS_KEY]) @@ -59,7 +61,6 @@ public static function withersDataProvider(): iterable yield 'With values' => [[ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => '"', - CsvEncoder::ESCAPE_CHAR_KEY => '\\', CsvEncoder::KEY_SEPARATOR_KEY => '_', CsvEncoder::HEADERS_KEY => ['h1', 'h2'], CsvEncoder::ESCAPE_FORMULAS_KEY => true, @@ -72,7 +73,6 @@ public static function withersDataProvider(): iterable yield 'With null values' => [[ CsvEncoder::DELIMITER_KEY => null, CsvEncoder::ENCLOSURE_KEY => null, - CsvEncoder::ESCAPE_CHAR_KEY => null, CsvEncoder::KEY_SEPARATOR_KEY => null, CsvEncoder::HEADERS_KEY => null, CsvEncoder::ESCAPE_FORMULAS_KEY => null, @@ -88,7 +88,6 @@ public function testWithersWithoutValue() $context = $this->contextBuilder ->withDelimiter(null) ->withEnclosure(null) - ->withEscapeChar(null) ->withKeySeparator(null) ->withHeaders(null) ->withEscapedFormulas(null) @@ -101,7 +100,6 @@ public function testWithersWithoutValue() $this->assertSame([ CsvEncoder::DELIMITER_KEY => null, CsvEncoder::ENCLOSURE_KEY => null, - CsvEncoder::ESCAPE_CHAR_KEY => null, CsvEncoder::KEY_SEPARATOR_KEY => null, CsvEncoder::HEADERS_KEY => null, CsvEncoder::ESCAPE_FORMULAS_KEY => null, @@ -124,9 +122,25 @@ public function testCannotSetMultipleBytesAsEnclosure() $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/XmlEncoderContextBuilderTest.php b/Tests/Context/Encoder/XmlEncoderContextBuilderTest.php index d1ea307a9..2f71c6012 100644 --- a/Tests/Context/Encoder/XmlEncoderContextBuilderTest.php +++ b/Tests/Context/Encoder/XmlEncoderContextBuilderTest.php @@ -46,6 +46,7 @@ public function testWithers(array $values) ->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); @@ -67,6 +68,7 @@ public static function withersDataProvider(): iterable XmlEncoder::TYPE_CAST_ATTRIBUTES => true, XmlEncoder::VERSION => '1.0', XmlEncoder::CDATA_WRAPPING => false, + XmlEncoder::CDATA_WRAPPING_PATTERN => '/[<>&"\']/', ]]; yield 'With null values' => [[ @@ -83,6 +85,7 @@ public static function withersDataProvider(): iterable XmlEncoder::TYPE_CAST_ATTRIBUTES => null, XmlEncoder::VERSION => null, XmlEncoder::CDATA_WRAPPING => null, + XmlEncoder::CDATA_WRAPPING_PATTERN => null, ]]; } } diff --git a/Tests/Context/Normalizer/AbstractNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/AbstractNormalizerContextBuilderTest.php index 4b8f0cc3f..4e92c54d8 100644 --- a/Tests/Context/Normalizer/AbstractNormalizerContextBuilderTest.php +++ b/Tests/Context/Normalizer/AbstractNormalizerContextBuilderTest.php @@ -25,7 +25,7 @@ class AbstractNormalizerContextBuilderTest extends TestCase protected function setUp(): void { - $this->contextBuilder = new class() extends AbstractNormalizerContextBuilder {}; + $this->contextBuilder = new class extends AbstractNormalizerContextBuilder {}; } /** diff --git a/Tests/Context/Normalizer/AbstractObjectNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/AbstractObjectNormalizerContextBuilderTest.php index 410f2972b..c13760118 100644 --- a/Tests/Context/Normalizer/AbstractObjectNormalizerContextBuilderTest.php +++ b/Tests/Context/Normalizer/AbstractObjectNormalizerContextBuilderTest.php @@ -25,7 +25,7 @@ class AbstractObjectNormalizerContextBuilderTest extends TestCase protected function setUp(): void { - $this->contextBuilder = new class() extends AbstractObjectNormalizerContextBuilder {}; + $this->contextBuilder = new class extends AbstractObjectNormalizerContextBuilder {}; } /** diff --git a/Tests/Context/Normalizer/DateTimeNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/DateTimeNormalizerContextBuilderTest.php index 8ab41f949..ac4badc19 100644 --- a/Tests/Context/Normalizer/DateTimeNormalizerContextBuilderTest.php +++ b/Tests/Context/Normalizer/DateTimeNormalizerContextBuilderTest.php @@ -38,6 +38,7 @@ 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); @@ -51,11 +52,13 @@ 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, ]]; } diff --git a/Tests/DataCollector/SerializerDataCollectorTest.php b/Tests/DataCollector/SerializerDataCollectorTest.php index aebde8efa..6a26565a8 100644 --- a/Tests/DataCollector/SerializerDataCollectorTest.php +++ b/Tests/DataCollector/SerializerDataCollectorTest.php @@ -25,8 +25,8 @@ 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); - $dataCollector->collectDeserialize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 1.0, $caller); + $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()); @@ -41,6 +41,7 @@ public function testCollectSerialize() 'normalization' => [], 'encoding' => [], 'caller' => $caller, + 'name' => 'default', ]], $collectedData['serialize']); $this->assertSame([[ @@ -53,6 +54,7 @@ public function testCollectSerialize() 'normalization' => [], 'encoding' => [], 'caller' => $caller, + 'name' => 'default', ]], $collectedData['deserialize']); } @@ -61,8 +63,8 @@ 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); - $dataCollector->collectDenormalize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 1.0, $caller); + $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()); @@ -77,6 +79,7 @@ public function testCollectNormalize() 'normalization' => [], 'encoding' => [], 'caller' => $caller, + 'name' => 'default', ]], $collectedData['normalize']); $this->assertSame([[ @@ -89,6 +92,7 @@ public function testCollectNormalize() 'normalization' => [], 'encoding' => [], 'caller' => $caller, + 'name' => 'default', ]], $collectedData['denormalize']); } @@ -97,8 +101,8 @@ 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); - $dataCollector->collectDecode('traceIdTwo', 'data', 'format', ['foo' => 'bar'], 1.0, $caller); + $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()); @@ -113,6 +117,7 @@ public function testCollectEncode() 'normalization' => [], 'encoding' => [], 'caller' => $caller, + 'name' => 'default', ]], $collectedData['encode']); $this->assertSame([[ @@ -125,6 +130,7 @@ public function testCollectEncode() 'normalization' => [], 'encoding' => [], 'caller' => $caller, + 'name' => 'default', ]], $collectedData['decode']); } @@ -133,18 +139,18 @@ 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); - $dataCollector->collectDenormalize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 20.0, $caller); + $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); - $dataCollector->collectNormalization('traceIdOne', DateTimeNormalizer::class, 2.0); - $dataCollector->collectNormalization('traceIdOne', ObjectNormalizer::class, 5.0); - $dataCollector->collectNormalization('traceIdOne', ObjectNormalizer::class, 10.0); + $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); - $dataCollector->collectNormalization('traceIdTwo', DateTimeNormalizer::class, 2.0); - $dataCollector->collectNormalization('traceIdTwo', ObjectNormalizer::class, 5.0); - $dataCollector->collectNormalization('traceIdTwo', ObjectNormalizer::class, 10.0); + $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(); @@ -189,18 +195,18 @@ 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); - $dataCollector->collectDecode('traceIdTwo', 'data', 'format', ['foo' => 'bar'], 20.0, $caller); + $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); - $dataCollector->collectEncoding('traceIdOne', JsonEncoder::class, 2.0); - $dataCollector->collectEncoding('traceIdOne', CsvEncoder::class, 5.0); - $dataCollector->collectEncoding('traceIdOne', CsvEncoder::class, 10.0); + $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); - $dataCollector->collectDecoding('traceIdTwo', JsonEncoder::class, 2.0); - $dataCollector->collectDecoding('traceIdTwo', CsvEncoder::class, 5.0); - $dataCollector->collectDecoding('traceIdTwo', CsvEncoder::class, 10.0); + $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(); @@ -245,13 +251,13 @@ 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); - $dataCollector->collectDeserialize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 1.0, $caller); - $dataCollector->collectNormalize('traceIdThree', 'data', 'format', ['foo' => 'bar'], 20.0, $caller); - $dataCollector->collectDenormalize('traceIdFour', 'data', 'type', 'format', ['foo' => 'bar'], 20.0, $caller); - $dataCollector->collectEncode('traceIdFive', 'data', 'format', ['foo' => 'bar'], 20.0, $caller); - $dataCollector->collectDecode('traceIdSix', 'data', 'format', ['foo' => 'bar'], 20.0, $caller); - $dataCollector->collectSerialize('traceIdSeven', 'data', 'format', ['foo' => 'bar'], 1.0, $caller); + $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(); @@ -264,13 +270,13 @@ public function testGetTotalTime() $caller = ['name' => 'Foo.php', 'file' => 'src/Foo.php', 'line' => 123]; - $dataCollector->collectSerialize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0, $caller); - $dataCollector->collectDeserialize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 2.0, $caller); - $dataCollector->collectNormalize('traceIdThree', 'data', 'format', ['foo' => 'bar'], 3.0, $caller); - $dataCollector->collectDenormalize('traceIdFour', 'data', 'type', 'format', ['foo' => 'bar'], 4.0, $caller); - $dataCollector->collectEncode('traceIdFive', 'data', 'format', ['foo' => 'bar'], 5.0, $caller); - $dataCollector->collectDecode('traceIdSix', 'data', 'format', ['foo' => 'bar'], 6.0, $caller); - $dataCollector->collectSerialize('traceIdSeven', 'data', 'format', ['foo' => 'bar'], 7.0, $caller); + $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(); @@ -282,7 +288,7 @@ 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); + $dataCollector->collectSerialize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); $dataCollector->lateCollect(); $this->assertNotSame([], $dataCollector->getData()); @@ -295,10 +301,10 @@ public function testDoNotCollectPartialTraces() { $dataCollector = new SerializerDataCollector(); - $dataCollector->collectNormalization('traceIdOne', DateTimeNormalizer::class, 1.0); - $dataCollector->collectDenormalization('traceIdTwo', DateTimeNormalizer::class, 1.0); - $dataCollector->collectEncoding('traceIdThree', CsvEncoder::class, 10.0); - $dataCollector->collectDecoding('traceIdFour', JsonEncoder::class, 1.0); + $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(); @@ -312,6 +318,84 @@ public function testDoNotCollectPartialTraces() $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. */ diff --git a/Tests/Debug/TraceableEncoderTest.php b/Tests/Debug/TraceableEncoderTest.php index ec38c0ef5..2ac0b8f1f 100644 --- a/Tests/Debug/TraceableEncoderTest.php +++ b/Tests/Debug/TraceableEncoderTest.php @@ -36,12 +36,14 @@ public function testForwardsToEncoder() ->with('data', 'format', $this->isType('array')) ->willReturn('decoded'); - $this->assertSame('encoded', (new TraceableEncoder($encoder, new SerializerDataCollector()))->encode('data', 'format')); - $this->assertSame('decoded', (new TraceableEncoder($decoder, new SerializerDataCollector()))->decode('data', 'format')); + $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); @@ -49,14 +51,14 @@ public function testCollectEncodingData() $dataCollector ->expects($this->once()) ->method('collectEncoding') - ->with($this->isType('string'), $encoder::class, $this->isType('float')); + ->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')); + ->with($this->isType('string'), $decoder::class, $this->isType('float'), $serializerName); - (new TraceableEncoder($encoder, $dataCollector))->encode('data', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); - (new TraceableEncoder($decoder, $dataCollector))->decode('data', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); + (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() @@ -68,22 +70,22 @@ public function testNotCollectEncodingDataIfNoDebugTraceId() $dataCollector->expects($this->never())->method('collectEncoding'); $dataCollector->expects($this->never())->method('collectDecoding'); - (new TraceableEncoder($encoder, $dataCollector))->encode('data', 'format'); - (new TraceableEncoder($decoder, $dataCollector))->decode('data', 'format'); + (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()))->encode('data', 'format'); + (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()))->decode('data', 'format'); + (new TraceableEncoder($this->createMock(EncoderInterface::class), new SerializerDataCollector(), 'default'))->decode('data', 'format'); } public function testSupports() @@ -94,8 +96,8 @@ public function testSupports() $decoder = $this->createMock(DecoderInterface::class); $decoder->method('supportsDecoding')->willReturn(true); - $traceableEncoder = new TraceableEncoder($encoder, new SerializerDataCollector()); - $traceableDecoder = new TraceableEncoder($decoder, new SerializerDataCollector()); + $traceableEncoder = new TraceableEncoder($encoder, new SerializerDataCollector(), 'default'); + $traceableDecoder = new TraceableEncoder($decoder, new SerializerDataCollector(), 'default'); $this->assertTrue($traceableEncoder->supportsEncoding('data')); $this->assertTrue($traceableDecoder->supportsDecoding('data')); diff --git a/Tests/Debug/TraceableNormalizerTest.php b/Tests/Debug/TraceableNormalizerTest.php index 307bc7b6f..56c161392 100644 --- a/Tests/Debug/TraceableNormalizerTest.php +++ b/Tests/Debug/TraceableNormalizerTest.php @@ -38,12 +38,14 @@ public function testForwardsToNormalizer() ->with('data', 'type', 'format', $this->isType('array')) ->willReturn('denormalized'); - $this->assertSame('normalized', (new TraceableNormalizer($normalizer, new SerializerDataCollector()))->normalize('data', 'format')); - $this->assertSame('denormalized', (new TraceableNormalizer($denormalizer, new SerializerDataCollector()))->denormalize('data', 'type', 'format')); + $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); @@ -53,14 +55,14 @@ public function testCollectNormalizationData() $dataCollector ->expects($this->once()) ->method('collectNormalization') - ->with($this->isType('string'), $normalizer::class, $this->isType('float')); + ->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')); + ->with($this->isType('string'), $denormalizer::class, $this->isType('float'), $serializerName); - (new TraceableNormalizer($normalizer, $dataCollector))->normalize('data', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); - (new TraceableNormalizer($denormalizer, $dataCollector))->denormalize('data', 'type', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); + (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() @@ -74,22 +76,22 @@ public function testNotCollectNormalizationDataIfNoDebugTraceId() $dataCollector->expects($this->never())->method('collectNormalization'); $dataCollector->expects($this->never())->method('collectDenormalization'); - (new TraceableNormalizer($normalizer, $dataCollector))->normalize('data', 'format'); - (new TraceableNormalizer($denormalizer, $dataCollector))->denormalize('data', 'type', 'format'); + (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()))->normalize('data'); + (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()))->denormalize('data', 'type'); + (new TraceableNormalizer($this->createMock(NormalizerInterface::class), new SerializerDataCollector(), 'default'))->denormalize('data', 'type'); } public function testSupports() @@ -102,8 +104,8 @@ public function testSupports() $denormalizer->method('getSupportedTypes')->willReturn(['*' => false]); $denormalizer->method('supportsDenormalization')->willReturn(true); - $traceableNormalizer = new TraceableNormalizer($normalizer, new SerializerDataCollector()); - $traceableDenormalizer = new TraceableNormalizer($denormalizer, new SerializerDataCollector()); + $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')); diff --git a/Tests/Debug/TraceableSerializerTest.php b/Tests/Debug/TraceableSerializerTest.php index ea3c851c6..d697b270f 100644 --- a/Tests/Debug/TraceableSerializerTest.php +++ b/Tests/Debug/TraceableSerializerTest.php @@ -56,7 +56,7 @@ public function testForwardsToSerializer() ->with('data', 'format', $this->isType('array')) ->willReturn('decoded'); - $traceableSerializer = new TraceableSerializer($serializer, new SerializerDataCollector()); + $traceableSerializer = new TraceableSerializer($serializer, new SerializerDataCollector(), 'default'); $this->assertSame('serialized', $traceableSerializer->serialize('data', 'format')); $this->assertSame('deserialized', $traceableSerializer->deserialize('data', 'type', 'format')); @@ -68,33 +68,35 @@ public function testForwardsToSerializer() 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')); + ->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')); + ->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')); + ->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')); + ->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')); + ->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')); + ->with($this->isType('string'), 'data', 'format', $this->isType('array'), $this->isType('float'), $this->isType('array'), $serializerName); - $traceableSerializer = new TraceableSerializer(new Serializer(), $dataCollector); + $traceableSerializer = new TraceableSerializer(new Serializer(), $dataCollector, $serializerName); $traceableSerializer->serialize('data', 'format'); $traceableSerializer->deserialize('data', 'type', 'format'); @@ -117,7 +119,7 @@ public function testAddDebugTraceIdInContext() }); } - $traceableSerializer = new TraceableSerializer($serializer, new SerializerDataCollector()); + $traceableSerializer = new TraceableSerializer($serializer, new SerializerDataCollector(), 'default'); $traceableSerializer->serialize('data', 'format'); $traceableSerializer->deserialize('data', 'format', 'type'); diff --git a/Tests/DependencyInjection/SerializerPassTest.php b/Tests/DependencyInjection/SerializerPassTest.php index eb77263f4..88ec02b87 100644 --- a/Tests/DependencyInjection/SerializerPassTest.php +++ b/Tests/DependencyInjection/SerializerPassTest.php @@ -17,7 +17,10 @@ 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. @@ -28,20 +31,20 @@ 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') @@ -50,6 +53,10 @@ public function testThrowExceptionWhenNoEncoders() $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); } @@ -77,9 +84,11 @@ public function testServicesAreOrderedAccordingToPriority() public function testBindSerializerDefaultContext() { + $context = ['enable_max_depth' => true]; + $container = new ContainerBuilder(); $container->setParameter('kernel.debug', false); - $container->register('serializer')->setArguments([null, null]); + $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'); @@ -87,10 +96,37 @@ 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 testNormalizersAndEncodersAreDecoredAndOrderedWhenCollectingData() + public function testNormalizersAndEncodersAreDecoratedAndOrderedWhenCollectingData() { $container = new ContainerBuilder(); @@ -110,9 +146,560 @@ public function testNormalizersAndEncodersAreDecoredAndOrderedWhenCollectingData $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 index 2b3c94cb8..fc78db51c 100644 --- a/Tests/Dummy/DummyClassOne.php +++ b/Tests/Dummy/DummyClassOne.php @@ -16,6 +16,7 @@ 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 { @@ -29,5 +30,6 @@ class DummyClassOne )] public string $code; + #[SerializedPath('[data][name]')] public string $name; } diff --git a/Tests/Encoder/CsvEncoderTest.php b/Tests/Encoder/CsvEncoderTest.php index 13c0dcc08..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,6 +21,8 @@ */ class CsvEncoderTest extends TestCase { + use ExpectUserDeprecationMessageTrait; + private CsvEncoder $encoder; protected function setUp(): void @@ -149,7 +152,6 @@ public function testEncodeCustomSettings() $this->encoder = new CsvEncoder([ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => '|', CsvEncoder::KEY_SEPARATOR_KEY => '-', ]); @@ -175,7 +177,6 @@ public function testEncodeCustomSettingsPassedInContext() , $this->encoder->encode($value, 'csv', [ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => '|', CsvEncoder::KEY_SEPARATOR_KEY => '-', ])); } @@ -185,7 +186,6 @@ public function testEncodeCustomSettingsPassedInConstructor() $encoder = new CsvEncoder([ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => '|', CsvEncoder::KEY_SEPARATOR_KEY => '-', ]); $value = ['a' => 'he\'llo', 'c' => ['d' => 'foo']]; @@ -574,7 +574,6 @@ public function testDecodeCustomSettings() $this->encoder = new CsvEncoder([ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => '|', CsvEncoder::KEY_SEPARATOR_KEY => '-', ]); @@ -596,7 +595,6 @@ public function testDecodeCustomSettingsPassedInContext() , 'csv', [ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => '|', CsvEncoder::KEY_SEPARATOR_KEY => '-', ])); } @@ -606,7 +604,6 @@ public function testDecodeCustomSettingsPassedInConstructor() $encoder = new CsvEncoder([ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => '|', CsvEncoder::KEY_SEPARATOR_KEY => '-', CsvEncoder::AS_COLLECTION_KEY => true, // Can be removed in 5.0 ]); @@ -710,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 66cd10114..f336bcd42 100644 --- a/Tests/Encoder/JsonDecodeTest.php +++ b/Tests/Encoder/JsonDecodeTest.php @@ -47,11 +47,9 @@ 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]], ]; } diff --git a/Tests/Encoder/JsonEncoderTest.php b/Tests/Encoder/JsonEncoderTest.php index 1b47684ae..a34e82c6b 100644 --- a/Tests/Encoder/JsonEncoderTest.php +++ b/Tests/Encoder/JsonEncoderTest.php @@ -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 97de86abf..0eb332e80 100644 --- a/Tests/Encoder/XmlEncoderTest.php +++ b/Tests/Encoder/XmlEncoderTest.php @@ -39,46 +39,39 @@ protected function setUp(): void $this->encoder->setSerializer($serializer); } - public function testEncodeScalar() + /** + * @dataProvider validEncodeProvider + */ + public function testEncode(string $expected, mixed $data, array $context = []) { - $obj = new ScalarDummy(); - $obj->xmlFoo = 'foo'; - - $expected = ''."\n". - 'foo'."\n"; - - $this->assertEquals($expected, $this->encoder->encode($obj, 'xml')); + $this->assertSame($expected, $this->encoder->encode($data, 'xml', $context)); } - public function testEncodeArrayObject() + /** + * @return iterable + */ + public static function validEncodeProvider(): iterable { - $obj = new \ArrayObject(['foo' => 'bar']); - - $expected = ''."\n". - 'bar'."\n"; - - $this->assertEquals($expected, $this->encoder->encode($obj, 'xml')); - } - - public function testEncodeEmptyArrayObject() - { - $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' => [ @@ -100,7 +93,9 @@ public function testAttributes() '@sring' => 'a', ], ]; - $expected = ''."\n". + + yield 'attributes' => [ + ''."\n". ''. ''. 'Test'. @@ -110,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', @@ -123,70 +116,54 @@ 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', 'age' => null]]; - $expected = <<<'XML' + yield 'encode with context' => [ + <<<'XML' @@ -195,88 +172,139 @@ public function testContext() -XML; - - $context = [ - 'xml_format_output' => true, - 'save_options' => \LIBXML_NOEMPTYTAG, +XML, + ['person' => ['name' => 'George Abitbol', 'age' => null]], + [ + 'xml_format_output' => true, + 'save_options' => \LIBXML_NOEMPTYTAG, + ], ]; - $this->assertSame($expected, $this->encoder->encode($array, 'xml', $context)); - } + yield 'encode scalar root attributes' => [ + ''."\n". + 'Paul'."\n", + [ + '#' => 'Paul', + '@eye-color' => 'brown', + ], + ]; - public function testEncodeScalarRootAttributes() - { - $array = [ - '#' => 'Paul', - '@eye-color' => 'brown', + yield 'encode root attributes' => [ + ''."\n". + 'Paul'."\n", + [ + 'firstname' => 'Paul', + '@eye-color' => 'brown', + ], ]; - $expected = ''."\n". - 'Paul'."\n"; + yield 'encode with CDATA wrapping with default pattern #1' => [ + ''."\n". + ']]>'."\n", + ['firstname' => 'Paul & Martha '], + ]; - $this->assertEquals($expected, $this->encoder->encode($array, 'xml')); - } + yield 'encode with CDATA wrapping with default pattern #2' => [ + ''."\n". + 'O\'Donnel'."\n", + ['lastname' => 'O\'Donnel'], + ]; - public function testEncodeRootAttributes() - { - $array = [ - 'firstname' => 'Paul', - '@eye-color' => 'brown', + yield 'encode with CDATA wrapping with default pattern #3' => [ + ''."\n". + ''."\n", + ['firstname' => 'Paul & Martha'], ]; - $expected = ''."\n". - 'Paul'."\n"; + yield 'encode with CDATA wrapping with custom pattern #1' => [ + ''."\n". + ']]>'."\n", + ['firstname' => 'Paul & Martha '], + ['cdata_wrapping_pattern' => '/[<>&"\']/'], + ]; - $this->assertEquals($expected, $this->encoder->encode($array, 'xml')); - } + yield 'encode with CDATA wrapping with custom pattern #2' => [ + ''."\n". + ''."\n", + ['lastname' => 'O\'Donnel'], + ['cdata_wrapping_pattern' => '/[<>&"\']/'], + ]; - public function testEncodeCdataWrapping() - { - $array = [ - 'firstname' => 'Paul & Martha ', + yield 'encode with CDATA wrapping with custom pattern #3' => [ + ''."\n". + 'Paul and Martha'."\n", + ['firstname' => 'Paul and Martha'], + ['cdata_wrapping_pattern' => '/[<>&"\']/'], ]; - $expected = ''."\n". - ']]>'."\n"; + yield 'enable CDATA wrapping' => [ + ''."\n". + ']]>'."\n", + ['firstname' => 'Paul & Martha '], + ['cdata_wrapping' => true], + ]; - $this->assertEquals($expected, $this->encoder->encode($array, 'xml')); - } + yield 'disable CDATA wrapping' => [ + ''."\n". + 'Paul & Martha <or Me>'."\n", + ['firstname' => 'Paul & Martha '], + ['cdata_wrapping' => false], + ]; - public function testEnableCdataWrapping() - { - $array = [ - 'firstname' => 'Paul & Martha ', + yield 'encode scalar with attribute' => [ + ''."\n". + 'Peter'."\n", + ['person' => ['@eye-color' => 'brown', '#' => 'Peter']], ]; - $expected = ''."\n". - ']]>'."\n"; + yield 'encode' => [ + self::getXmlSource(), + self::getObject(), + ]; - $this->assertEquals($expected, $this->encoder->encode($array, 'xml', ['cdata_wrapping' => true])); + yield 'encode with namespace' => [ + self::getNamespacedXmlSource(), + self::getNamespacedArray(), + ]; } - public function testDisableCdataWrapping() + public function testEncodeSerializerXmlRootNodeNameOption() { + $options = ['xml_root_node_name' => 'test']; + $this->encoder = new XmlEncoder(); + $serializer = new Serializer([], ['xml' => new XmlEncoder()]); + $this->encoder->setSerializer($serializer); + $array = [ - 'firstname' => 'Paul & Martha ', + 'person' => ['@eye-color' => 'brown', '#' => 'Peter'], ]; $expected = ''."\n". - 'Paul & Martha <or Me>'."\n"; + 'Peter'."\n"; - $this->assertEquals($expected, $this->encoder->encode($array, 'xml', ['cdata_wrapping' => false])); + $this->assertSame($expected, $serializer->serialize($array, 'xml', $options)); } - public function testEncodeScalarWithAttribute() + public function testEncodeTraversableWhenNormalizable() { - $array = [ - 'person' => ['@eye-color' => 'brown', '#' => 'Peter'], - ]; + $this->encoder = new XmlEncoder(); + $serializer = new Serializer([new CustomNormalizer()], ['xml' => new XmlEncoder()]); + $this->encoder->setSerializer($serializer); - $expected = ''."\n". - 'Peter'."\n"; + $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() @@ -284,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() @@ -385,52 +413,10 @@ public function testDoesNotTypeCastStringsStartingWith0() $this->assertSame('018', $data['@a']); } - public function testEncode() - { - $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() + public function testEncodeException() { - $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() @@ -625,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')); @@ -649,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, @@ -684,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')); @@ -770,7 +756,7 @@ public function testDecodeEmptyXml() $this->encoder->decode(' ', 'xml'); } - protected function getXmlSource() + protected static function getXmlSource(): string { return ''."\n". ''. @@ -783,7 +769,7 @@ protected function getXmlSource() ''."\n"; } - protected function getNamespacedXmlSource() + protected static function getNamespacedXmlSource(): string { return ''."\n". ''. @@ -796,7 +782,7 @@ protected function getNamespacedXmlSource() ''."\n"; } - protected function getNamespacedArray() + protected static function getNamespacedArray(): array { return [ '@xmlns' => 'http://www.w3.org/2005/Atom', @@ -830,7 +816,10 @@ protected function getNamespacedArray() ]; } - protected function getObject() + /** + * @return Dummy + */ + protected static function getObject(): object { $obj = new Dummy(); $obj->foo = 'foo'; @@ -1004,14 +993,14 @@ private function createMockDateTimeNormalizer(): MockObject&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 33ee49f5d..f647fe423 100644 --- a/Tests/Encoder/YamlEncoderTest.php +++ b/Tests/Encoder/YamlEncoderTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Encoder\YamlEncoder; +use Symfony\Component\Serializer\Exception\NotEncodableValueException; use Symfony\Component\Yaml\Yaml; /** @@ -81,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/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/GroupDummy.php b/Tests/Fixtures/Attributes/GroupDummy.php index 749e841a5..41457d796 100644 --- a/Tests/Fixtures/Attributes/GroupDummy.php +++ b/Tests/Fixtures/Attributes/GroupDummy.php @@ -12,7 +12,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; use Symfony\Component\Serializer\Attribute\Groups; -use Symfony\Component\Serializer\Tests\Fixtures\ChildOfGroupsAnnotationDummy; +use Symfony\Component\Serializer\Tests\Fixtures\ChildOfGroupsAttributeDummy; /** * @author Kévin Dunglas @@ -23,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/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/ChildOfGroupsAnnotationDummy.php b/Tests/Fixtures/ChildOfGroupsAttributeDummy.php similarity index 82% rename from Tests/Fixtures/ChildOfGroupsAnnotationDummy.php rename to Tests/Fixtures/ChildOfGroupsAttributeDummy.php index 9a163012f..4fc81fa9d 100644 --- a/Tests/Fixtures/ChildOfGroupsAnnotationDummy.php +++ b/Tests/Fixtures/ChildOfGroupsAttributeDummy.php @@ -5,7 +5,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[\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/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/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/Mapping/Factory/CacheMetadataFactoryTest.php b/Tests/Mapping/Factory/CacheMetadataFactoryTest.php index 9525ca605..e18dd707f 100644 --- a/Tests/Mapping/Factory/CacheMetadataFactoryTest.php +++ b/Tests/Mapping/Factory/CacheMetadataFactoryTest.php @@ -58,16 +58,17 @@ 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($anonymousObject::class); diff --git a/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php b/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php index 20b057211..40dcb5015 100644 --- a/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php +++ b/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php @@ -27,7 +27,7 @@ final class ClassMetadataFactoryCompilerTest extends TestCase 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 diff --git a/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php b/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php index 683f445df..ff54fb96b 100644 --- a/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php +++ b/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php @@ -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/AttributeLoaderTest.php b/Tests/Mapping/Loader/AttributeLoaderTest.php index a76595506..2af244a6f 100644 --- a/Tests/Mapping/Loader/AttributeLoaderTest.php +++ b/Tests/Mapping/Loader/AttributeLoaderTest.php @@ -23,6 +23,7 @@ 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; @@ -33,7 +34,7 @@ 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\IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations; +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; @@ -162,7 +163,7 @@ public function testLoadContextsPropertiesPromoted() public function testThrowsOnContextOnInvalidMethod() { $this->expectException(MappingException::class); - $this->expectExceptionMessage(sprintf('Context on "%s::badMethod()" cannot be added', BadMethodContextDummy::class)); + $this->expectExceptionMessage(\sprintf('Context on "%s::badMethod()" cannot be added', BadMethodContextDummy::class)); $loader = $this->getLoaderForContextMapping(); @@ -181,7 +182,7 @@ public function testCanHandleUnrelatedIgnoredMethods() $this->assertSame(['id'], array_keys($metadata->getAttributesMetadata())); } - public function testIgnoreGetterWithRequiredParameterIfIgnoreAnnotationIsUsed() + public function testIgnoreGetterWithRequiredParameterIfIgnoreAttributeIsUsed() { $classMetadata = new ClassMetadata(IgnoreDummyAdditionalGetter::class); $this->getLoaderForContextMapping()->loadClassMetadata($classMetadata); @@ -191,9 +192,9 @@ public function testIgnoreGetterWithRequiredParameterIfIgnoreAnnotationIsUsed() self::assertArrayHasKey('extraValue2', $attributes); } - public function testIgnoreGetterWithRequiredParameterIfIgnoreAnnotationIsNotUsed() + public function testIgnoreGetterWithRequiredParameterIfIgnoreAttributeIsNotUsed() { - $classMetadata = new ClassMetadata(IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations::class); + $classMetadata = new ClassMetadata(IgnoreDummyAdditionalGetterWithoutIgnoreAttributes::class); $this->getLoaderForContextMapping()->loadClassMetadata($classMetadata); $attributes = $classMetadata->getAttributesMetadata(); @@ -229,6 +230,22 @@ public function testLoadWithInvalidAttribute() $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/YamlFileLoaderTest.php b/Tests/Mapping/Loader/YamlFileLoaderTest.php index ea81a9d8a..48e95aecd 100644 --- a/Tests/Mapping/Loader/YamlFileLoaderTest.php +++ b/Tests/Mapping/Loader/YamlFileLoaderTest.php @@ -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); } 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/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 a32574878..74ca3d6d6 100644 --- a/Tests/Normalizer/AbstractNormalizerTest.php +++ b/Tests/Normalizer/AbstractNormalizerTest.php @@ -271,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); } diff --git a/Tests/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index 1809b967c..270b65f33 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -13,9 +13,11 @@ 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\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\DiscriminatorMap; use Symfony\Component\Serializer\Attribute\SerializedName; @@ -37,6 +39,7 @@ 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; @@ -56,6 +59,7 @@ 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 { @@ -82,10 +86,12 @@ public function testInstantiateObjectDenormalizer() public function testDenormalizeWithExtraAttribute() { - $this->expectException(ExtraAttributesException::class); - $this->expectExceptionMessage('Extra attributes are not allowed ("fooFoo" is unknown).'); $factory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new AbstractObjectNormalizerDummy($factory); + + $this->expectException(ExtraAttributesException::class); + $this->expectExceptionMessage('Extra attributes are not allowed ("fooFoo" is unknown).'); + $normalizer->denormalize( ['fooFoo' => 'foo'], Dummy::class, @@ -96,10 +102,12 @@ public function testDenormalizeWithExtraAttribute() public function testDenormalizeWithExtraAttributes() { - $this->expectException(ExtraAttributesException::class); - $this->expectExceptionMessage('Extra attributes are not allowed ("fooFoo", "fooBar" are unknown).'); $factory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new AbstractObjectNormalizerDummy($factory); + + $this->expectException(ExtraAttributesException::class); + $this->expectExceptionMessage('Extra attributes are not allowed ("fooFoo", "fooBar" are unknown).'); + $normalizer->denormalize( ['fooFoo' => 'foo', 'fooBar' => 'bar'], Dummy::class, @@ -110,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, @@ -134,9 +144,11 @@ public function testDenormalizePlainObject() 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 = new AbstractObjectNormalizerWithMetadata(); + $normalizer->denormalize([], DuplicateValueNestedDummy::class, 'any'); } @@ -204,8 +216,6 @@ public function testDenormalizeWithNestedAttributes() public function testDenormalizeWithNestedAttributesDuplicateKeys() { - $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 = new AbstractObjectNormalizerWithMetadata(); $data = [ 'one' => [ @@ -213,6 +223,10 @@ public function testDenormalizeWithNestedAttributesDuplicateKeys() ], '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'); } @@ -265,25 +279,29 @@ public function testDenormalizeWithNestedAttributesInConstructorAndDiscriminator public function testNormalizeWithNestedAttributesMixingArrayTypes() { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('The element you are trying to set is already populated: "[one][two]"'); $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() { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('The element you are trying to set is already populated: "[one][two][three]"'); $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'); } @@ -419,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(); @@ -474,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(); @@ -493,7 +529,7 @@ public function testDenormalizeWithDiscriminatorMapUsesCorrectClassname() { $factory = new ClassMetadataFactory(new AttributeLoader()); - $loaderMock = new class() implements ClassMetadataFactoryInterface { + $loaderMock = new class implements ClassMetadataFactoryInterface { public function getMetadataFor($value): ClassMetadataInterface { if (AbstractDummy::class === $value) { @@ -528,7 +564,7 @@ public function testDenormalizeWithDiscriminatorMapAndObjectToPopulateUsesCorrec { $factory = new ClassMetadataFactory(new AttributeLoader()); - $loaderMock = new class() implements ClassMetadataFactoryInterface { + $loaderMock = new class implements ClassMetadataFactoryInterface { public function getMetadataFor($value): ClassMetadataInterface { if (AbstractDummy::class === $value) { @@ -581,7 +617,7 @@ public function hasMetadataFor($value): bool public function testDenormalizeWithNestedDiscriminatorMap() { - $classDiscriminatorResolver = new class() implements ClassDiscriminatorResolverInterface { + $classDiscriminatorResolver = new class implements ClassDiscriminatorResolverInterface { public function getMappingForClass(string $class): ?ClassDiscriminatorMapping { return match ($class) { @@ -661,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(); @@ -691,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, @@ -814,7 +870,7 @@ public function testDenormalizeWithNumberAsSerializedNameAndNoArrayReindex() '99' => 'baz', ]; - $obj = new class() { + $obj = new class { #[SerializedName('1')] public $foo; @@ -838,7 +894,7 @@ public function testDenormalizeWithCorrectOrderOfAttributeAndProperty() ], ]; - $obj = new class() { + $obj = new class { #[SerializedPath('[data][id]')] public $id; }; @@ -849,7 +905,7 @@ public function testDenormalizeWithCorrectOrderOfAttributeAndProperty() public function testNormalizeBasedOnAllowedAttributes() { - $normalizer = new class() extends AbstractObjectNormalizer { + $normalizer = new class extends AbstractObjectNormalizer { public function getSupportedTypes(?string $format): array { return ['*' => false]; @@ -929,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 @@ -969,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 @@ -1004,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 @@ -1034,7 +1090,7 @@ 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 AttributeLoader()))); @@ -1078,6 +1134,52 @@ public function testNormalizationWithMaxDepthOnStdclassObjectDoesNotThrowWarning $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 */ @@ -1090,13 +1192,127 @@ public function testDenormalizeBooleanTypesWithNotMatchingData(array $data, stri $normalizer->denormalize($data, $type); } - public function provideBooleanTypesData() + public static function provideBooleanTypesData() { return [ [['foo' => true], FalsePropertyDummy::class], [['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 @@ -1199,6 +1415,11 @@ class SnakeCaseNestedDummy public $fooBar; } +class MixedPropertyDummy +{ + public mixed $foo; +} + #[DiscriminatorMap(typeProperty: 'type', mapping: [ 'first' => FirstNestedDummyWithConstructorAndDiscriminator::class, 'second' => SecondNestedDummyWithConstructorAndDiscriminator::class, @@ -1377,6 +1598,18 @@ class TruePropertyDummy public $foo; } +class BoolPropertyDummy +{ + /** @var bool|null */ + public $foo; +} + +class DummyWithArrayObject +{ + /** @var \ArrayObject */ + public $foo; +} + class SerializerCollectionDummy implements SerializerInterface, DenormalizerInterface { private array $normalizers; @@ -1521,16 +1754,45 @@ public function __construct( } } -class AbstractObjectNormalizerWithMetadataAndPropertyTypeExtractors extends AbstractObjectNormalizer +#[DiscriminatorMap('type', ['foo' => ScalarCollectionDocBlockDummy::class])] +class ScalarCollectionDocBlockDummy { - public function __construct() + /** + * @param array>|null $values + */ + public function __construct( + private readonly ?array $values = null, + ) { + } + + /** @return array>|null */ + public function getValues(): ?array { - parent::__construct(new ClassMetadataFactory(new AttributeLoader()), null, new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()])); + return $this->values; } +} - public function getSupportedTypes(?string $format): array +class UnionCollectionDocBlockDummy +{ + /** + * @param array $values1 + */ + public function __construct( + public array $values1, + ) { + } + + /** + * @var array + */ + public array $values2; +} + +class AbstractObjectNormalizerWithMetadataAndPropertyTypeExtractors extends AbstractObjectNormalizer +{ + public function __construct() { - return ['*' => false]; + parent::__construct(new ClassMetadataFactory(new AttributeLoader()), null, new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()])); } protected function extractAttributes(object $object, ?string $format = null, array $context = []): array @@ -1543,10 +1805,45 @@ protected function getAttributeValue(object $object, string $attribute, ?string 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/ConstraintViolationListNormalizerTest.php b/Tests/Normalizer/ConstraintViolationListNormalizerTest.php index bb69392f5..1d0afb3cb 100644 --- a/Tests/Normalizer/ConstraintViolationListNormalizerTest.php +++ b/Tests/Normalizer/ConstraintViolationListNormalizerTest.php @@ -50,23 +50,23 @@ public function testNormalize() 'detail' => 'd: a 4: 1', 'violations' => [ - [ - 'propertyPath' => 'd', - 'title' => 'a', - 'template' => 'b', - 'type' => 'urn:uuid:f', - 'parameters' => [ - 'value' => 'foo', - ], - ], - [ - 'propertyPath' => '4', - 'title' => '1', - 'template' => '2', - '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)); diff --git a/Tests/Normalizer/DataUriNormalizerTest.php b/Tests/Normalizer/DataUriNormalizerTest.php index 92e173fe0..7e9af4360 100644 --- a/Tests/Normalizer/DataUriNormalizerTest.php +++ b/Tests/Normalizer/DataUriNormalizerTest.php @@ -121,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'); @@ -148,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/DateTimeNormalizerTest.php b/Tests/Normalizer/DateTimeNormalizerTest.php index e65b6f67d..81219652b 100644 --- a/Tests/Normalizer/DateTimeNormalizerTest.php +++ b/Tests/Normalizer/DateTimeNormalizerTest.php @@ -154,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); @@ -166,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')); } @@ -175,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'])); } @@ -321,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/Features/CallbacksTestTrait.php b/Tests/Normalizer/Features/CallbacksTestTrait.php index 09e2f8867..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,7 +101,7 @@ public function testDenormalizeCallbacksWithNoConstructorArgument($callbacks, $v { $normalizer = $this->getNormalizerForCallbacksWithPropertyTypeExtractor(); - $objWithNoConstructorArgument = new class() extends CallbacksObject { + $objWithNoConstructorArgument = new class extends CallbacksObject { public function __construct() { } diff --git a/Tests/Normalizer/Features/CircularReferenceTestTrait.php b/Tests/Normalizer/Features/CircularReferenceTestTrait.php index d02e245f7..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).', $obj::class, $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); } diff --git a/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php b/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php index 72652f340..0a5f6f249 100644 --- a/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php +++ b/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php @@ -64,10 +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(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(\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 10f5a0030..787157628 100644 --- a/Tests/Normalizer/Features/ContextMetadataTestTrait.php +++ b/Tests/Normalizer/Features/ContextMetadataTestTrait.php @@ -77,7 +77,7 @@ public function testContextMetadataContextDenormalize(string $contextMetadataDum self::assertEquals('2011-07-28', $dummy->date->format('Y-m-d'), 'a specific denormalization context is used for this group'); } - public function contextMetadataDummyProvider(): array + public static function contextMetadataDummyProvider(): array { return [ [ContextMetadataDummy::class], 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 08d5e065a..621ceec41 100644 --- a/Tests/Normalizer/Features/GroupsTestTrait.php +++ b/Tests/Normalizer/Features/GroupsTestTrait.php @@ -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/GetSetMethodNormalizerTest.php b/Tests/Normalizer/GetSetMethodNormalizerTest.php index ca5d25910..80788a0d2 100644 --- a/Tests/Normalizer/GetSetMethodNormalizerTest.php +++ b/Tests/Normalizer/GetSetMethodNormalizerTest.php @@ -39,6 +39,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 +54,7 @@ class GetSetMethodNormalizerTest extends TestCase use CallbacksTestTrait; use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; + use FilterBoolTestTrait; use GroupsTestTrait; use IgnoredAttributesTestTrait; use MaxDepthTestTrait; @@ -279,6 +281,11 @@ protected function getDenormalizerForGroups(): GetSetMethodNormalizer return new GetSetMethodNormalizer($classMetadataFactory); } + protected function getNormalizerForFilterBool(): GetSetMethodNormalizer + { + return new GetSetMethodNormalizer(); + } + public function testGroupsNormalizeWithNameConverter() { $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); @@ -375,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); @@ -384,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'); } @@ -392,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() @@ -515,6 +521,14 @@ public function testNormalizeWithDiscriminator() $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 AttributeLoader()); @@ -902,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 54a977f55..f8f8546d7 100644 --- a/Tests/Normalizer/JsonSerializableNormalizerTest.php +++ b/Tests/Normalizer/JsonSerializableNormalizerTest.php @@ -68,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 ea4515955..75ecbccb9 100644 --- a/Tests/Normalizer/MapDenormalizationTest.php +++ b/Tests/Normalizer/MapDenormalizationTest.php @@ -187,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) { diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index 70a342c27..d45586b44 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -14,12 +14,14 @@ 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\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; @@ -50,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; @@ -72,6 +75,7 @@ class ObjectNormalizerTest extends TestCase use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; use ContextMetadataTestTrait; + use FilterBoolTestTrait; use GroupsTestTrait; use IgnoredAttributesTestTrait; use MaxDepthTestTrait; @@ -323,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' => [ @@ -337,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); } @@ -360,6 +365,11 @@ protected function getDenormalizerForAttributes(): ObjectNormalizer return $normalizer; } + protected function getNormalizerForFilterBool(): ObjectNormalizer + { + return new ObjectNormalizer(); + } + public function testAttributesContextDenormalizeConstructor() { $normalizer = new ObjectNormalizer(null, null, null, new ReflectionExtractor()); @@ -639,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); @@ -648,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'); } @@ -736,7 +747,7 @@ 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'], (new class() { + $this->assertSame('bar', $serializer->denormalize(['foo' => 'bar'], (new class { /** @var self::*|null */ public $foo; })::class)->foo); @@ -771,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']); } }; @@ -854,6 +865,24 @@ public function testNormalizeStdClass() $this->assertSame(['baz' => 'baz'], $this->normalizer->normalize($o2)); } + 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() { $normalizer = new ObjectNormalizer(new ClassMetadataFactory(new AttributeLoader())); @@ -922,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 @@ -1204,3 +1261,63 @@ class ObjectDummyWithIgnoreAttributeAndPrivateProperty 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/PropertyNormalizerTest.php b/Tests/Normalizer/PropertyNormalizerTest.php index 0601a2d60..ecdae436e 100644 --- a/Tests/Normalizer/PropertyNormalizerTest.php +++ b/Tests/Normalizer/PropertyNormalizerTest.php @@ -38,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; @@ -52,6 +53,7 @@ class PropertyNormalizerTest extends TestCase use CallbacksTestTrait; use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; + use FilterBoolTestTrait; use GroupsTestTrait; use IgnoredAttributesTestTrait; use MaxDepthTestTrait; @@ -184,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') ); } @@ -248,6 +259,11 @@ protected function getSelfReferencingModel() return new PropertyCircularReferenceDummy(); } + protected function getNormalizerForFilterBool(): PropertyNormalizer + { + return new PropertyNormalizer(); + } + public function testSiblingReference() { $serializer = new Serializer([$this->normalizer]); @@ -320,12 +336,16 @@ public function testGroupsDenormalizeWithNameConverter() $this->assertEquals( $obj, - $this->normalizer->denormalize([ - 'bar' => null, - 'foo_bar' => '@dunglas', - 'symfony' => '@coopTilleuls', - 'coop_tilleuls' => 'les-tilleuls.coop', - ], GroupDummy::class, 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']] + ) ); } @@ -385,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); @@ -399,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); @@ -408,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'); } @@ -429,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'], @@ -446,7 +474,7 @@ public function testMultiDimensionObject() RootDummy::class, 'any' ); - $this->assertEquals($root::class, RootDummy::class); + $this->assertSame(RootDummy::class, $root::class); // children (two dimension array) $this->assertCount(1, $root->children); @@ -514,7 +542,13 @@ public function testDenormalizeWithDiscriminator() $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 + ) + ); } } diff --git a/Tests/Normalizer/UidNormalizerTest.php b/Tests/Normalizer/UidNormalizerTest.php index 1471074a0..734b15b48 100644 --- a/Tests/Normalizer/UidNormalizerTest.php +++ b/Tests/Normalizer/UidNormalizerTest.php @@ -47,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', diff --git a/Tests/SerializerTest.php b/Tests/SerializerTest.php index 0a65c3f20..8fd0ff885 100644 --- a/Tests/SerializerTest.php +++ b/Tests/SerializerTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Tests; 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; @@ -63,6 +64,7 @@ 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; @@ -97,8 +99,10 @@ public function testItThrowsExceptionOnInvalidEncoder() public function testNormalizeNoMatch() { - $this->expectException(UnexpectedValueException::class); $serializer = new Serializer([$this->createMock(NormalizerInterface::class)]); + + $this->expectException(UnexpectedValueException::class); + $serializer->normalize(new \stdClass(), 'xml'); } @@ -118,15 +122,19 @@ 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() { - $this->expectException(UnexpectedValueException::class); $serializer = new Serializer([$this->createMock(NormalizerInterface::class)]); + + $this->expectException(UnexpectedValueException::class); + $serializer->denormalize('foo', 'stdClass'); } @@ -140,9 +148,11 @@ public function testDenormalizeOnObjectThatOnlySupportsDenormalization() 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')); } @@ -237,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'); } @@ -271,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'); } @@ -407,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) { @@ -588,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()) { @@ -689,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'); } @@ -727,8 +755,10 @@ 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'); } @@ -1185,7 +1215,7 @@ 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); } @@ -1225,7 +1255,7 @@ public function testCollectDenormalizationErrorsWithoutTypeExtractor() [ 'currentType' => 'array', 'expectedTypes' => [ - 'unknown', + class_exists(InvalidTypeException::class) ? 'string' : 'unknown', ], 'path' => 'string', 'useMessageForUser' => false, @@ -1234,7 +1264,7 @@ public function testCollectDenormalizationErrorsWithoutTypeExtractor() [ 'currentType' => 'array', 'expectedTypes' => [ - 'unknown', + class_exists(InvalidTypeException::class) ? 'int' : 'unknown', ], 'path' => 'int', 'useMessageForUser' => false, @@ -1243,7 +1273,7 @@ public function testCollectDenormalizationErrorsWithoutTypeExtractor() [ 'currentType' => 'array', 'expectedTypes' => [ - 'unknown', + class_exists(InvalidTypeException::class) ? 'float' : 'unknown', ], 'path' => 'float', 'useMessageForUser' => false, @@ -1435,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); } @@ -1631,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 diff --git a/composer.json b/composer.json index 627bfccaf..d8809fa07 100644 --- a/composer.json +++ b/composer.json @@ -17,15 +17,17 @@ ], "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8" }, "require-dev": { "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.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": "^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", @@ -36,6 +38,7 @@ "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",