From e3d226932d50a5357c6d0bf6b2bc4e7bb007d54e Mon Sep 17 00:00:00 2001 From: Maxime Steinhausser Date: Thu, 28 Jul 2022 11:28:07 +0200 Subject: [PATCH 1/2] [Serializer] Deprecate using datetime construct as fallback on default format mismatch --- src/Symfony/Component/Serializer/CHANGELOG.md | 1 + .../Normalizer/DateTimeNormalizer.php | 2 ++ .../Normalizer/DateTimeNormalizerTest.php | 35 +++++++++++++++++-- .../Tests/Normalizer/ObjectNormalizerTest.php | 2 +- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 7c2dd31143551..8fc9ba1a9433a 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -22,6 +22,7 @@ CHANGELOG * `JsonSerializableNormalizer` * `ObjectNormalizer` * `PropertyNormalizer` + * Deprecate datetime constructor as a fallback whenever the `DateTimeNormalizer` default format mismatches 6.2 --- diff --git a/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php index 43faf9ad15248..84e876928d114 100644 --- a/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php @@ -120,6 +120,8 @@ public function denormalize(mixed $data, string $type, string $format = null, ar if (false !== $object) { return $object; } + + trigger_deprecation('symfony/serializer', '6.2', 'Relying on a datetime constructor as a fallback when using a specific default date format (`datetime_format`) for the DateTimeNormalizer is deprecated. Respect the "%s" default format.', $defaultDateTimeFormat); } try { diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php index 8f368deca68b3..5af8ee201ea4c 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; @@ -21,6 +22,8 @@ */ class DateTimeNormalizerTest extends TestCase { + use ExpectDeprecationTrait; + /** * @var DateTimeNormalizer */ @@ -177,7 +180,13 @@ public function testDenormalize() $this->assertEquals(new \DateTimeImmutable('2016/01/01', new \DateTimeZone('UTC')), $this->normalizer->denormalize('2016-01-01T00:00:00+00:00', \DateTimeInterface::class)); $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)); + } + + public function testDenormalizeWithoutFormat() + { + $normalizer = new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => null]); + + $this->assertEquals(new \DateTime('2016/01/01', new \DateTimeZone('UTC')), $normalizer->denormalize(' 2016-01-01T00:00:00+00:00 ', \DateTime::class)); } public function testDenormalizeUsingTimezonePassedInConstructor() @@ -203,7 +212,9 @@ public function testDenormalizeUsingFormatPassedInContext() */ public function testDenormalizeUsingTimezonePassedInContext($input, $expected, $timezone, $format = null) { - $actual = $this->normalizer->denormalize($input, \DateTimeInterface::class, null, [ + $normalizer = new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => 'Y/m/d H:i:s']); + + $actual = $normalizer->denormalize($input, \DateTimeInterface::class, null, [ DateTimeNormalizer::TIMEZONE_KEY => $timezone, DateTimeNormalizer::FORMAT_KEY => $format, ]); @@ -236,13 +247,26 @@ public static function denormalizeUsingTimezonePassedInContextProvider() \DateTime::RFC3339, ]; } - + /** + * Deprecation will be removed as of 7.0, but this test case is still legit + * TODO: remove the @group legacy and expectDeprecation in Symfony 7.0 + * + * @group legacy + */ public function testDenormalizeInvalidDataThrowsException() { + $this->expectDeprecation('Since symfony/serializer 6.2: Relying on a datetime constructor as a fallback when using a specific default date format (`datetime_format`) for the DateTimeNormalizer is deprecated. Respect the "Y-m-d\TH:i:sP" default format.'); + $this->expectException(UnexpectedValueException::class); $this->normalizer->denormalize('invalid date', \DateTimeInterface::class); } + public function testDenormalizeWithFormatAndInvalidDataThrowsException() + { + $this->expectException(UnexpectedValueException::class); + $this->normalizer->denormalize('invalid date', \DateTimeInterface::class, null, [DateTimeNormalizer::FORMAT_KEY => 'Y.m.d']); + } + public function testDenormalizeNullThrowsException() { $this->expectException(UnexpectedValueException::class); @@ -282,8 +306,13 @@ public function testDenormalizeDateTimeStringWithDefaultContextFormat() $this->assertSame('01/10/2018', $denormalizedDate->format($format)); } + /** + * @group legacy + */ public function testDenormalizeDateTimeStringWithDefaultContextAllowsErrorFormat() { + $this->expectDeprecation('Since symfony/serializer 6.2: Relying on a datetime constructor as a fallback when using a specific default date format (`datetime_format`) for the DateTimeNormalizer is deprecated. Respect the "d/m/Y" default format.'); + $format = 'd/m/Y'; // the default format $string = '2020-01-01'; // the value which is in the wrong format, but is accepted because of `new \DateTime` in DateTimeNormalizer::denormalize diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php index 5eba0707c67ea..bb53b190f963e 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php @@ -707,7 +707,7 @@ public function testDenomalizeRecursive() $obj = $serializer->denormalize([ 'inner' => ['foo' => 'foo', 'bar' => 'bar'], - 'date' => '1988/01/21', + 'date' => '1988-01-21T00:00:00+00:00', 'inners' => [['foo' => 1], ['foo' => 2]], ], ObjectOuter::class); From 41a1d5676ac376c3d5bc0db9c74a6712d5faaf7e Mon Sep 17 00:00:00 2001 From: Maxime Steinhausser Date: Tue, 27 Dec 2022 10:34:07 +0100 Subject: [PATCH 2/2] [Serializer] Add the auto format to explicitly use PHP datetime constructor --- UPGRADE-6.3.md | 7 +++++ src/Symfony/Component/Serializer/CHANGELOG.md | 1 + .../DateTimeNormalizerContextBuilder.php | 10 ++++++ .../Normalizer/DateTimeNormalizer.php | 31 ++++++++++++++++++- .../DateTimeNormalizerContextBuilderTest.php | 5 +++ .../Normalizer/DateTimeNormalizerTest.php | 25 +++++++++++++-- 6 files changed, 75 insertions(+), 4 deletions(-) diff --git a/UPGRADE-6.3.md b/UPGRADE-6.3.md index 06928dde31914..dc77c18c333ab 100644 --- a/UPGRADE-6.3.md +++ b/UPGRADE-6.3.md @@ -107,6 +107,13 @@ SecurityBundle * Deprecate enabling bundle and not configuring it * Deprecate the `security.firewalls.logout.csrf_token_generator` config option, use `security.firewalls.logout.csrf_token_manager` instead +Serializer +---------- + +* Deprecate datetime constructor as a fallback whenever the `DateTimeNormalizer` + default format mismatches. Use the `DateTimeNormalizer::FORMAT_AUTO` when + denormalizing to explicitly rely on the PHP datetime constructor instead. + Validator --------- diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 8fc9ba1a9433a..831b62b271aa8 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -23,6 +23,7 @@ CHANGELOG * `ObjectNormalizer` * `PropertyNormalizer` * Deprecate datetime constructor as a fallback whenever the `DateTimeNormalizer` default format mismatches + * Add `DateTimeNormalizer::FORMAT_AUTO` to denormalize datetime objects using the PHP datetime constructor 6.2 --- diff --git a/src/Symfony/Component/Serializer/Context/Normalizer/DateTimeNormalizerContextBuilder.php b/src/Symfony/Component/Serializer/Context/Normalizer/DateTimeNormalizerContextBuilder.php index 99517afb1d8d4..d640d72377298 100644 --- a/src/Symfony/Component/Serializer/Context/Normalizer/DateTimeNormalizerContextBuilder.php +++ b/src/Symfony/Component/Serializer/Context/Normalizer/DateTimeNormalizerContextBuilder.php @@ -35,6 +35,16 @@ public function withFormat(?string $format): static return $this->with(DateTimeNormalizer::FORMAT_KEY, $format); } + /** + * Configures the denormalization format of the date to use PHP datetime construct. + * + * @see https://www.php.net/manual/en/datetime.construct.php + */ + public function withAutoDenormalizationFormat(): static + { + return $this->with(DateTimeNormalizer::FORMAT_KEY, DateTimeNormalizer::FORMAT_AUTO); + } + /** * Configures the timezone of the date. * diff --git a/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php index 84e876928d114..fdc0647895d8c 100644 --- a/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php @@ -13,6 +13,7 @@ use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; /** @@ -26,6 +27,10 @@ class DateTimeNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface { public const FORMAT_KEY = 'datetime_format'; + /** + * Use this value as the `datetime_format` to use the datetime constructor when denormalizing. + */ + public const FORMAT_AUTO = 'auto'; public const TIMEZONE_KEY = 'datetime_timezone'; private $defaultContext = [ @@ -46,6 +51,10 @@ public function __construct(array $defaultContext = []) public function setDefaultContext(array $defaultContext): void { + if (($defaultContext[self::FORMAT_KEY] ?? null) === self::FORMAT_AUTO) { + throw new LogicException(sprintf('The "%s" format cannot is not supported in the default "%s" context key. Use this format on specific context when denormalizing.', self::FORMAT_AUTO, self::FORMAT_KEY)); + } + $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } @@ -70,6 +79,11 @@ public function normalize(mixed $object, string $format = null, array $context = } $dateTimeFormat = $context[self::FORMAT_KEY] ?? $this->defaultContext[self::FORMAT_KEY]; + + if (self::FORMAT_AUTO === $dateTimeFormat) { + throw new LogicException(sprintf('The "%s" format cannot is not supported in the "%s" context key when normalizing. Use this format on specific context when denormalizing.', self::FORMAT_AUTO, self::FORMAT_KEY)); + } + $timezone = $this->getTimezone($context); if (null !== $timezone) { @@ -101,6 +115,17 @@ public function denormalize(mixed $data, string $type, string $format = null, ar } if (null !== $dateTimeFormat) { + // If we specifically asked for the auto format on denormalization context: + if (self::FORMAT_AUTO === $dateTimeFormat) { + try { + // use the constructor to create the DateTime object: + return \DateTime::class === $type ? new \DateTime($data, $timezone) : new \DateTimeImmutable($data, $timezone); + } catch (\Exception $e) { + throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, false, $e->getCode(), $e); + } + } + + // Otherwise, use the provided format: $object = \DateTime::class === $type ? \DateTime::createFromFormat($dateTimeFormat, $data, $timezone) : \DateTimeImmutable::createFromFormat($dateTimeFormat, $data, $timezone); if (false !== $object) { @@ -121,7 +146,11 @@ public function denormalize(mixed $data, string $type, string $format = null, ar return $object; } - trigger_deprecation('symfony/serializer', '6.2', 'Relying on a datetime constructor as a fallback when using a specific default date format (`datetime_format`) for the DateTimeNormalizer is deprecated. Respect the "%s" default format.', $defaultDateTimeFormat); + // TODO: Throw a NotNormalizableValueException exception in Symfony 7.0+ instead of the deprecation: + // $dateTimeErrors = \DateTime::class === $type ? \DateTime::getLastErrors() : \DateTimeImmutable::getLastErrors(); + // throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Parsing datetime string "%s" using format "%s" resulted in %d errors: ', $data, $defaultDateTimeFormat, $dateTimeErrors['error_count'])."\n".implode("\n", $this->formatDateTimeErrors($dateTimeErrors['errors'])), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true); + + trigger_deprecation('symfony/serializer', '6.3', 'Relying on a datetime constructor as a fallback when using a specific default date format (`datetime_format`) for the DateTimeNormalizer is deprecated. Respect the "%s" default format or use the "auto" format in denormalization context.', $defaultDateTimeFormat); } try { diff --git a/src/Symfony/Component/Serializer/Tests/Context/Normalizer/DateTimeNormalizerContextBuilderTest.php b/src/Symfony/Component/Serializer/Tests/Context/Normalizer/DateTimeNormalizerContextBuilderTest.php index 8ab41f949c3cc..f917df0a7ebec 100644 --- a/src/Symfony/Component/Serializer/Tests/Context/Normalizer/DateTimeNormalizerContextBuilderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Context/Normalizer/DateTimeNormalizerContextBuilderTest.php @@ -57,6 +57,11 @@ public static function withersDataProvider(): iterable DateTimeNormalizer::FORMAT_KEY => null, DateTimeNormalizer::TIMEZONE_KEY => null, ]]; + + yield 'With auto format' => [[ + DateTimeNormalizer::FORMAT_KEY => DateTimeNormalizer::FORMAT_KEY, + DateTimeNormalizer::TIMEZONE_KEY => null, + ]]; } public function testCastTimezoneStringToTimezone() diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php index 5af8ee201ea4c..f302017ac0bef 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/DateTimeNormalizerTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; @@ -58,6 +59,15 @@ public function testNormalizeUsingFormatPassedInConstructor() $this->assertEquals('16', $normalizer->normalize(new \DateTime('2016/01/01', new \DateTimeZone('UTC')))); } + public function testCannotUseAutoFormatWhileNormalizing() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "auto" format cannot is not supported in the "datetime_format" context key when normalizing. Use this format on specific context when denormalizing.'); + + $normalizer = new DateTimeNormalizer(); + $normalizer->normalize(new \DateTime('2016/01/01', new \DateTimeZone('UTC')), null, [DateTimeNormalizer::FORMAT_KEY => DateTimeNormalizer::FORMAT_AUTO]); + } + public function testNormalizeUsingTimeZonePassedInConstructor() { $normalizer = new DateTimeNormalizer([DateTimeNormalizer::TIMEZONE_KEY => new \DateTimeZone('Japan')]); @@ -247,15 +257,16 @@ public static function denormalizeUsingTimezonePassedInContextProvider() \DateTime::RFC3339, ]; } + /** * Deprecation will be removed as of 7.0, but this test case is still legit - * TODO: remove the @group legacy and expectDeprecation in Symfony 7.0 + * TODO: remove the @group legacy and expectDeprecation in Symfony 7.0. * * @group legacy */ public function testDenormalizeInvalidDataThrowsException() { - $this->expectDeprecation('Since symfony/serializer 6.2: Relying on a datetime constructor as a fallback when using a specific default date format (`datetime_format`) for the DateTimeNormalizer is deprecated. Respect the "Y-m-d\TH:i:sP" default format.'); + $this->expectDeprecation('Since symfony/serializer 6.3: Relying on a datetime constructor as a fallback when using a specific default date format (`datetime_format`) for the DateTimeNormalizer is deprecated. Respect the "Y-m-d\TH:i:sP" default format or use the "auto" format in denormalization context.'); $this->expectException(UnexpectedValueException::class); $this->normalizer->denormalize('invalid date', \DateTimeInterface::class); @@ -295,6 +306,14 @@ public function testDenormalizeDateTimeStringWithSpacesUsingFormatPassedInContex $this->normalizer->denormalize(' 2016.01.01 ', \DateTime::class, null, [DateTimeNormalizer::FORMAT_KEY => 'Y.m.d|']); } + public function testCannotUseAutoFormatInDefaultContext() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "auto" format cannot is not supported in the default "datetime_format" context key. Use this format on specific context when denormalizing.'); + + new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => DateTimeNormalizer::FORMAT_AUTO]); + } + public function testDenormalizeDateTimeStringWithDefaultContextFormat() { $format = 'd/m/Y'; @@ -311,7 +330,7 @@ public function testDenormalizeDateTimeStringWithDefaultContextFormat() */ public function testDenormalizeDateTimeStringWithDefaultContextAllowsErrorFormat() { - $this->expectDeprecation('Since symfony/serializer 6.2: Relying on a datetime constructor as a fallback when using a specific default date format (`datetime_format`) for the DateTimeNormalizer is deprecated. Respect the "d/m/Y" default format.'); + $this->expectDeprecation('Since symfony/serializer 6.3: Relying on a datetime constructor as a fallback when using a specific default date format (`datetime_format`) for the DateTimeNormalizer is deprecated. Respect the "d/m/Y" default format or use the "auto" format in denormalization context.'); $format = 'd/m/Y'; // the default format $string = '2020-01-01'; // the value which is in the wrong format, but is accepted because of `new \DateTime` in DateTimeNormalizer::denormalize