Skip to content

[Serializer] Add support for denormalizing invalid datetime without throwing an exception #42236

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions UPGRADE-5.4.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,8 @@ Security
* Deprecate `TokenInterface:isAuthenticated()` and `setAuthenticated()` methods without replacement.
Security tokens won't have an "authenticated" flag anymore, so they will always be considered authenticated
* Deprecate `DeauthenticatedEvent`, use `TokenDeauthenticatedEvent` instead

Serializer
----------

* Deprecate not setting a value for the context key `DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY`
1 change: 1 addition & 0 deletions UPGRADE-6.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ Serializer
* Removed `ArrayDenormalizer::setSerializer()`, call `setDenormalizer()` instead.
* `ArrayDenormalizer` does not implement `SerializerAwareInterface` anymore.
* The annotation classes cannot be constructed by passing an array of parameters as first argument anymore, use named arguments instead
* The default context value for `DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY` becomes `false`.

TwigBundle
----------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ framework:

services:
logger: { class: Psr\Log\NullLogger }

serializer.normalizer.datetime:
class: Symfony\Component\Serializer\Normalizer\DateTimeNormalizer
arguments:
$defaultContext:
throw_exception_on_invalid_key: false
1 change: 1 addition & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ CHANGELOG
---

* Add support of PHP backed enumerations
* Add support for denormalizing invalid datetime without throwing an exception

5.3
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,21 @@
/**
* Normalizes an object implementing the {@see \DateTimeInterface} to a date string.
* Denormalizes a date string to an instance of {@see \DateTime} or {@see \DateTimeImmutable}.
* The denormalization may return the raw data if invalid according to the value of $context[self::THROW_EXCEPTION_ON_INVALID_KEY].
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class DateTimeNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface
{
public const FORMAT_KEY = 'datetime_format';
public const TIMEZONE_KEY = 'datetime_timezone';
public const THROW_EXCEPTION_ON_INVALID_KEY = 'throw_exception_on_invalid_key';

private $defaultContext = [
self::FORMAT_KEY => \DateTime::RFC3339,
self::TIMEZONE_KEY => null,
// BC layer to be moved to "false" in 6.0
self::THROW_EXCEPTION_ON_INVALID_KEY => null,
];

private const SUPPORTED_TYPES = [
Expand All @@ -39,6 +43,10 @@ class DateTimeNormalizer implements NormalizerInterface, DenormalizerInterface,
public function __construct(array $defaultContext = [])
{
$this->defaultContext = array_merge($this->defaultContext, $defaultContext);

if (null === $this->defaultContext[self::THROW_EXCEPTION_ON_INVALID_KEY]) {
trigger_deprecation('symfony/serializer', '5.4', 'The key context "%s" of "%s" must be defined. The value will be "false" in Symfony 6.0.', self::THROW_EXCEPTION_ON_INVALID_KEY, __CLASS__);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
trigger_deprecation('symfony/serializer', '5.4', 'The key context "%s" of "%s" must be defined. The value will be "false" in Symfony 6.0.', self::THROW_EXCEPTION_ON_INVALID_KEY, __CLASS__);
trigger_deprecation('symfony/serializer', '5.4', 'The key context "%s" of "%s" must be provided. The value will default to "false" in Symfony 6.0.', self::THROW_EXCEPTION_ON_INVALID_KEY, __CLASS__);

}
}

/**
Expand Down Expand Up @@ -77,15 +85,22 @@ public function supportsNormalization($data, string $format = null)
* {@inheritdoc}
*
* @throws NotNormalizableValueException
*
* @return \DateTimeInterface
*/
public function denormalize($data, string $type, string $format = null, array $context = [])
{
$dateTimeFormat = $context[self::FORMAT_KEY] ?? null;
$throwExceptionOnInvalid = $context[self::THROW_EXCEPTION_ON_INVALID_KEY] ?? $this->defaultContext[self::THROW_EXCEPTION_ON_INVALID_KEY];
// BC layer to be removed in 6.0
if (null === $throwExceptionOnInvalid) {
$throwExceptionOnInvalid = true;
}
$timezone = $this->getTimezone($context);

if (null === $data || (\is_string($data) && '' === trim($data))) {
if (!$throwExceptionOnInvalid) {
return $data;
}

throw new NotNormalizableValueException('The data is either an empty string or null, you should pass a string that can be parsed with the passed format or a valid DateTime string.');
}

Expand All @@ -98,12 +113,20 @@ public function denormalize($data, string $type, string $format = null, array $c

$dateTimeErrors = \DateTime::class === $type ? \DateTime::getLastErrors() : \DateTimeImmutable::getLastErrors();

if (!$throwExceptionOnInvalid) {
return $data;
}

throw new NotNormalizableValueException(sprintf('Parsing datetime string "%s" using format "%s" resulted in %d errors: ', $data, $dateTimeFormat, $dateTimeErrors['error_count'])."\n".implode("\n", $this->formatDateTimeErrors($dateTimeErrors['errors'])));
}

try {
return \DateTime::class === $type ? new \DateTime($data, $timezone) : new \DateTimeImmutable($data, $timezone);
} catch (\Exception $e) {
if (!$throwExceptionOnInvalid) {
return $data;
}

throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,14 +22,18 @@
*/
class DateTimeNormalizerTest extends TestCase
{
use ExpectDeprecationTrait;

/**
* @var DateTimeNormalizer
*/
private $normalizer;

protected function setUp(): void
{
$this->normalizer = new DateTimeNormalizer();
$this->normalizer = new DateTimeNormalizer([
DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => true,
]);
}

public function testSupportsNormalization()
Expand All @@ -51,13 +56,13 @@ public function testNormalizeUsingFormatPassedInContext()

public function testNormalizeUsingFormatPassedInConstructor()
{
$normalizer = new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => 'y']);
$normalizer = new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => 'y', DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => true]);
$this->assertEquals('16', $normalizer->normalize(new \DateTime('2016/01/01', new \DateTimeZone('UTC'))));
}

public function testNormalizeUsingTimeZonePassedInConstructor()
{
$normalizer = new DateTimeNormalizer([DateTimeNormalizer::TIMEZONE_KEY => new \DateTimeZone('Japan')]);
$normalizer = new DateTimeNormalizer([DateTimeNormalizer::TIMEZONE_KEY => new \DateTimeZone('Japan'), DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => true]);

$this->assertSame('2016-12-01T00:00:00+09:00', $normalizer->normalize(new \DateTime('2016/12/01', new \DateTimeZone('Japan'))));
$this->assertSame('2016-12-01T09:00:00+09:00', $normalizer->normalize(new \DateTime('2016/12/01', new \DateTimeZone('UTC'))));
Expand Down Expand Up @@ -184,7 +189,7 @@ public function testDenormalizeUsingTimezonePassedInConstructor()
{
$timezone = new \DateTimeZone('Japan');
$expected = new \DateTime('2016/12/01 17:35:00', $timezone);
$normalizer = new DateTimeNormalizer([DateTimeNormalizer::TIMEZONE_KEY => $timezone]);
$normalizer = new DateTimeNormalizer([DateTimeNormalizer::TIMEZONE_KEY => $timezone, DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => true]);

$this->assertEquals($expected, $normalizer->denormalize('2016.12.01 17:35:00', \DateTime::class, null, [
DateTimeNormalizer::FORMAT_KEY => 'Y.m.d H:i:s',
Expand Down Expand Up @@ -276,4 +281,31 @@ public function testDenormalizeFormatMismatchThrowsException()
$this->expectException(UnexpectedValueException::class);
$this->normalizer->denormalize('2016-01-01T00:00:00+00:00', \DateTimeInterface::class, null, [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d|']);
}

public function provideDenormalizeInvalidDataDontThrowsExceptionTests()
{
yield ['invalid date'];
yield [null];
yield [''];
yield [' '];
yield [' 2016.01.01 ', [DateTimeNormalizer::FORMAT_KEY => 'Y.m.d|']];
yield ['2016-01-01T00:00:00+00:00', [DateTimeNormalizer::FORMAT_KEY => 'Y.m.d|']];
}

/** @dataProvider provideDenormalizeInvalidDataDontThrowsExceptionTests */
public function testDenormalizeInvalidDataDontThrowsException($data, array $context = [])
{
$normalizer = new DateTimeNormalizer([DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => false]);
$this->assertSame($data, $normalizer->denormalize($data, \DateTimeInterface::class, null, $context));
}

/**
* @group legacy
*/
public function testLegacyConstructor()
{
$this->expectDeprecation('Since symfony/serializer 5.4: The key context "throw_exception_on_invalid_key" of "Symfony\Component\Serializer\Normalizer\DateTimeNormalizer" must be defined. The value will be "false" in Symfony 6.0.');

new DateTimeNormalizer();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function testContextMetadataNormalize()
{
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
$normalizer = new ObjectNormalizer($classMetadataFactory, null, null, new PhpDocExtractor());
new Serializer([new DateTimeNormalizer(), $normalizer]);
new Serializer([new DateTimeNormalizer([DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => false]), $normalizer]);

$dummy = new ContextMetadataDummy();
$dummy->date = new \DateTime('2011-07-28T08:44:00.123+00:00');
Expand All @@ -52,7 +52,7 @@ public function testContextMetadataContextDenormalize()
{
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
$normalizer = new ObjectNormalizer($classMetadataFactory, null, null, new PhpDocExtractor());
new Serializer([new DateTimeNormalizer(), $normalizer]);
new Serializer([new DateTimeNormalizer([DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => false]), $normalizer]);

/** @var ContextMetadataDummy $dummy */
$dummy = $normalizer->denormalize(['date' => '2011-07-28T08:44:00+00:00'], ContextMetadataDummy::class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ public function testDenomalizeRecursive()
{
$extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]);
$normalizer = new ObjectNormalizer(null, null, null, $extractor);
$serializer = new Serializer([new ArrayDenormalizer(), new DateTimeNormalizer(), $normalizer]);
$serializer = new Serializer([new ArrayDenormalizer(), new DateTimeNormalizer([DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => false]), $normalizer]);

$obj = $serializer->denormalize([
'inner' => ['foo' => 'foo', 'bar' => 'bar'],
Expand All @@ -638,7 +638,7 @@ public function testAcceptJsonNumber()
{
$extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]);
$normalizer = new ObjectNormalizer(null, null, null, $extractor);
$serializer = new Serializer([new ArrayDenormalizer(), new DateTimeNormalizer(), $normalizer]);
$serializer = new Serializer([new ArrayDenormalizer(), new DateTimeNormalizer([DateTimeNormalizer::THROW_EXCEPTION_ON_INVALID_KEY => false]), $normalizer]);

$this->assertSame(10.0, $serializer->denormalize(['number' => 10], JsonNumber::class, 'json')->number);
$this->assertSame(10.0, $serializer->denormalize(['number' => 10], JsonNumber::class, 'jsonld')->number);
Expand Down