Skip to content

Commit

Permalink
improvement(DateTime): handle multiple deserialization formats and ti…
Browse files Browse the repository at this point in the history
…mezones (#39)
  • Loading branch information
brutal-factories authored Feb 1, 2024
1 parent 1ee50dd commit 28e6f4a
Show file tree
Hide file tree
Showing 7 changed files with 87 additions and 16 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

# 2.6.0
* (De)serialization now accepts timezones, and lists of deserialization formats

# 2.5.1

* Generalized the improvement on arrays with primitive types to generate more efficient code.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"require": {
"php": "^8.0",
"ext-json": "*",
"liip/metadata-parser": "^1.1",
"liip/metadata-parser": "^1.2",
"pnz/json-exception": "^1.0",
"symfony/filesystem": "^4.4 || ^5.0 || ^6.0",
"symfony/finder": "^4.4 || ^5.0 || ^6.0",
Expand Down
11 changes: 4 additions & 7 deletions src/DeserializerGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,19 +205,16 @@ private function generateInnerCodeForFieldType(

switch ($type) {
case $type instanceof PropertyTypeArray:
if ($type->isCollection()) {
if ($type->isTraversable()) {
return $this->generateCodeForArrayCollection($propertyMetadata, $type, $arrayPath, $modelPropertyPath, $stack);
}

return $this->generateCodeForArray($type, $arrayPath, $modelPropertyPath, $stack);

case $type instanceof PropertyTypeDateTime:
if (null !== $type->getZone()) {
throw new \RuntimeException('Timezone support is not implemented');
}
$format = $type->getDeserializeFormat() ?: $type->getFormat();
if (null !== $format) {
return $this->templating->renderAssignDateTimeFromFormat($type->isImmutable(), (string) $modelPropertyPath, (string) $arrayPath, $format);
$formats = $type->getDeserializeFormats() ?: (\is_string($type->getFormat()) ? [$type->getFormat()] : $type->getFormat());
if (null !== $formats) {
return $this->templating->renderAssignDateTimeFromFormat($type->isImmutable(), (string) $modelPropertyPath, (string) $arrayPath, $formats, $type->getZone());
}

return $this->templating->renderAssignDateTimeToField($type->isImmutable(), (string) $modelPropertyPath, (string) $arrayPath);
Expand Down
5 changes: 1 addition & 4 deletions src/SerializerGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,7 @@ private function generateCodeForFieldType(
): string {
switch ($type) {
case $type instanceof PropertyTypeDateTime:
if (null !== $type->getZone()) {
throw new \RuntimeException('Timezone support is not implemented');
}
$dateFormat = $type->getFormat() ?: \DateTime::ISO8601;
$dateFormat = $type->getFormat() ?: \DateTimeInterface::ISO8601;

return $this->templating->renderAssign(
$fieldPath,
Expand Down
52 changes: 48 additions & 4 deletions src/Template/Deserialization.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,18 @@ function {{functionName}}(array {{jsonPath}}): {{className}}
EOT;

private const TMPL_ASSIGN_DATETIME_FROM_FORMAT = <<<'EOT'
{{modelPath}} = \DateTime::createFromFormat('{{format}}', {{jsonPath}});
{{date}} = false;
foreach([{{formats|join(', ')}}] as {{format}}) {
if (({{date}} = \DateTime::createFromFormat({{format}}, {{jsonPath}}, {{timezone}}))) {
{{modelPath}} = {{date}};
break;
}
}
if (false === {{date}}) {
throw new \Exception('Invalid datetime string '.({{jsonPath}}).' matches none of the deserialization formats: '.{{formatsError}});
}
unset({{format}}, {{date}});

EOT;

Expand All @@ -72,7 +83,18 @@ function {{functionName}}(array {{jsonPath}}): {{className}}
EOT;

private const TMPL_ASSIGN_DATETIME_IMMUTABLE_FROM_FORMAT = <<<'EOT'
{{modelPath}} = \DateTimeImmutable::createFromFormat('{{format}}', {{jsonPath}});
{{date}} = false;
foreach([{{formats|join(', ')}}] as {{format}}) {
if (({{date}} = \DateTimeImmutable::createFromFormat({{format}}, {{jsonPath}}, {{timezone}}))) {
{{modelPath}} = {{date}};
break;
}
}
if (false === {{date}}) {
throw new \Exception('Invalid datetime string '.({{jsonPath}}).' matches none of the deserialization formats: '.{{formatsError}});
}
unset({{format}}, {{date}});

EOT;

Expand Down Expand Up @@ -190,14 +212,36 @@ public function renderAssignDateTimeToField(bool $immutable, string $modelPath,
]);
}

public function renderAssignDateTimeFromFormat(bool $immutable, string $modelPath, string $jsonPath, string $format): string
/**
* @param list<string>|string $formats
*/
public function renderAssignDateTimeFromFormat(bool $immutable, string $modelPath, string $jsonPath, array|string $formats, string $timezone = null): string
{
if (\is_string($formats)) {
@trigger_error('Passing a string for argument $formats is deprecated, please pass an array of strings instead', \E_USER_DEPRECATED);
$formats = [$formats];
}

$template = $immutable ? self::TMPL_ASSIGN_DATETIME_IMMUTABLE_FROM_FORMAT : self::TMPL_ASSIGN_DATETIME_FROM_FORMAT;
$formats = array_map(
static fn (string $f): string => var_export($f, true),
$formats
);
$formatsError = var_export(implode(',', $formats), true);
$dateVariable = preg_replace_callback(
'/([^a-zA-Z]+|\d+)([a-zA-Z])/',
static fn ($match): string => (ctype_digit($match[1]) ? $match[1] : null).mb_strtoupper($match[2]),
$modelPath
);

return $this->render($template, [
'modelPath' => $modelPath,
'jsonPath' => $jsonPath,
'format' => $format,
'formats' => $formats,
'formatsError' => $formatsError,
'format' => '$'.lcfirst($dateVariable).'Format',
'date' => '$'.lcfirst($dateVariable),
'timezone' => $timezone ? 'new \DateTimeZone('.var_export($timezone, true).')' : 'null',
]);
}

Expand Down
21 changes: 21 additions & 0 deletions tests/Fixtures/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@ class Model
*/
public $dateWithFormat;

/**
* @Serializer\Type("DateTime<'Y-m-d', '', 'd/m/Y'>")
*
* @var \DateTime
*/
public $dateWithOneDeserializationFormat;

/**
* @Serializer\Type("DateTime<'Y-m-d', '', ['m/d/Y', 'Y-m-d']>")
*
* @var \DateTime
*/
public $dateWithMultipleDeserializationFormats;

/**
* @Serializer\Type("DateTime<'Y-m-d', '+0600', '!d/m/Y'>")
*
* @var \DateTime
*/
public $dateWithTimezone;

/**
* @Serializer\Type("DateTimeImmutable")
*
Expand Down
9 changes: 9 additions & 0 deletions tests/Unit/DeserializerGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ public function testNested(): void
'nested_field' => ['nested_string' => 'nested'],
'date' => '2018-08-03T00:00:00+02:00',
'date_with_format' => '2018-08-04',
'date_with_one_deserialization_format' => '15/05/2019',
'date_with_multiple_deserialization_formats' => '05/16/2019',
'date_with_timezone' => '04/08/2018', // Defined timezone offset is +6 hours, so bringing it back to UTC removes a day
'date_immutable' => '2016-06-01T00:00:00+02:00',
];

Expand All @@ -67,6 +70,12 @@ public function testNested(): void
self::assertSame('2018-08-03', $model->date->format('Y-m-d'));
self::assertInstanceOf(\DateTime::class, $model->dateWithFormat);
self::assertSame('2018-08-04', $model->dateWithFormat->format('Y-m-d'));
self::assertInstanceOf(\DateTime::class, $model->dateWithOneDeserializationFormat);
self::assertSame('2019-05-15', $model->dateWithOneDeserializationFormat->format('Y-m-d'));
self::assertInstanceOf(\DateTime::class, $model->dateWithMultipleDeserializationFormats);
self::assertSame('2019-05-16', $model->dateWithMultipleDeserializationFormats->format('Y-m-d'));
self::assertInstanceOf(\DateTime::class, $model->dateWithTimezone);
self::assertSame('2018-08-03', $model->dateWithTimezone->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d'));
self::assertInstanceOf(\DateTimeImmutable::class, $model->dateImmutable);
self::assertSame('2016-06-01', $model->dateImmutable->format('Y-m-d'));
}
Expand Down

0 comments on commit 28e6f4a

Please sign in to comment.