Skip to content

[Serializer] Deprecate using datetime construct as fallback on default format mismatch #47095

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
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
7 changes: 7 additions & 0 deletions UPGRADE-6.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------

Expand Down
2 changes: 2 additions & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ CHANGELOG
* `JsonSerializableNormalizer`
* `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
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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 = [
Expand All @@ -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);
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -120,6 +145,12 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
if (false !== $object) {
return $object;
}

// 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
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\LogicException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;

Expand All @@ -21,6 +23,8 @@
*/
class DateTimeNormalizerTest extends TestCase
{
use ExpectDeprecationTrait;

/**
* @var DateTimeNormalizer
*/
Expand Down Expand Up @@ -55,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')]);
Expand Down Expand Up @@ -177,7 +190,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()
Expand All @@ -203,7 +222,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,
]);
Expand Down Expand Up @@ -237,12 +258,26 @@ public static function denormalizeUsingTimezonePassedInContextProvider()
];
}

/**
* 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.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);
}

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);
Expand Down Expand Up @@ -271,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';
Expand All @@ -282,8 +325,13 @@ public function testDenormalizeDateTimeStringWithDefaultContextFormat()
$this->assertSame('01/10/2018', $denormalizedDate->format($format));
}

/**
* @group legacy
*/
public function testDenormalizeDateTimeStringWithDefaultContextAllowsErrorFormat()
{
$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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down