From 3726ee126a576568bb75736d757d8ed92fb8f05c Mon Sep 17 00:00:00 2001 From: Martin Parsiegla Date: Fri, 18 Jul 2025 16:09:13 +0200 Subject: [PATCH 1/7] Use `PropertyTypeIterable` instead of `PropertyTypeArray` The `PropertyTypeArray` class was removed in favor the `PropertyTypeIterable` one. --- composer.json | 2 +- src/DeserializerGenerator.php | 10 +++++----- src/Recursion.php | 4 ++-- src/SerializerGenerator.php | 14 +++++++------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index b907984..6dc57e6 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "require": { "php": "^8.0", "ext-json": "*", - "liip/metadata-parser": "^1.2", + "liip/metadata-parser": "^2.0", "pnz/json-exception": "^1.0", "symfony/filesystem": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", "symfony/finder": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", diff --git a/src/DeserializerGenerator.php b/src/DeserializerGenerator.php index 69debe3..0cc0ec6 100644 --- a/src/DeserializerGenerator.php +++ b/src/DeserializerGenerator.php @@ -7,7 +7,7 @@ use Liip\MetadataParser\Builder; use Liip\MetadataParser\Metadata\ClassMetadata; use Liip\MetadataParser\Metadata\PropertyMetadata; -use Liip\MetadataParser\Metadata\PropertyTypeArray; +use Liip\MetadataParser\Metadata\PropertyTypeIterable; use Liip\MetadataParser\Metadata\PropertyTypeClass; use Liip\MetadataParser\Metadata\PropertyTypeDateTime; use Liip\MetadataParser\Metadata\PropertyTypePrimitive; @@ -204,7 +204,7 @@ private function generateInnerCodeForFieldType( $type = $propertyMetadata->getType(); switch ($type) { - case $type instanceof PropertyTypeArray: + case $type instanceof PropertyTypeIterable: if ($type->isTraversable()) { return $this->generateCodeForArrayCollection($propertyMetadata, $type, $arrayPath, $modelPropertyPath, $stack); } @@ -238,7 +238,7 @@ private function generateInnerCodeForFieldType( * @param array $stack */ private function generateCodeForArray( - PropertyTypeArray $type, + PropertyTypeIterable $type, ArrayPath $arrayPath, ModelPath $modelPath, array $stack, @@ -254,7 +254,7 @@ private function generateCodeForArray( $subType = $type->getSubType(); switch ($subType) { - case $subType instanceof PropertyTypeArray: + case $subType instanceof PropertyTypeIterable: $innerCode = $this->generateCodeForArray($subType, $arrayPropertyPath, $modelPropertyPath, $stack); break; @@ -284,7 +284,7 @@ private function generateCodeForArray( */ private function generateCodeForArrayCollection( PropertyMetadata $propertyMetadata, - PropertyTypeArray $type, + PropertyTypeIterable $type, ArrayPath $arrayPath, ModelPath $modelPath, array $stack, diff --git a/src/Recursion.php b/src/Recursion.php index 69edc38..ce46425 100644 --- a/src/Recursion.php +++ b/src/Recursion.php @@ -5,7 +5,7 @@ namespace Liip\Serializer; use Liip\MetadataParser\Metadata\PropertyMetadata; -use Liip\MetadataParser\Metadata\PropertyTypeArray; +use Liip\MetadataParser\Metadata\PropertyTypeIterable; use Liip\MetadataParser\Metadata\PropertyTypeClass; abstract class Recursion @@ -44,7 +44,7 @@ public static function hasMaxDepthReached(PropertyMetadata $propertyMetadata, ar private static function getClassNameFromProperty(PropertyMetadata $propertyMetadata): ?string { $type = $propertyMetadata->getType(); - if ($type instanceof PropertyTypeArray) { + if ($type instanceof PropertyTypeIterable) { $type = $type->getLeafType(); } diff --git a/src/SerializerGenerator.php b/src/SerializerGenerator.php index 2915a70..4203c8a 100644 --- a/src/SerializerGenerator.php +++ b/src/SerializerGenerator.php @@ -8,9 +8,9 @@ use Liip\MetadataParser\Metadata\ClassMetadata; use Liip\MetadataParser\Metadata\PropertyMetadata; use Liip\MetadataParser\Metadata\PropertyType; -use Liip\MetadataParser\Metadata\PropertyTypeArray; use Liip\MetadataParser\Metadata\PropertyTypeClass; use Liip\MetadataParser\Metadata\PropertyTypeDateTime; +use Liip\MetadataParser\Metadata\PropertyTypeIterable; use Liip\MetadataParser\Metadata\PropertyTypePrimitive; use Liip\MetadataParser\Metadata\PropertyTypeUnknown; use Liip\MetadataParser\Reducer\GroupReducer; @@ -196,7 +196,7 @@ private function generateCodeForFieldType( case $type instanceof PropertyTypeClass: return $this->generateCodeForClass($type->getClassMetadata(), $apiVersion, $serializerGroups, $fieldPath, $modelPropertyPath, $stack); - case $type instanceof PropertyTypeArray: + case $type instanceof PropertyTypeIterable: return $this->generateCodeForArray($type, $apiVersion, $serializerGroups, $fieldPath, $modelPropertyPath, $stack); default: @@ -209,7 +209,7 @@ private function generateCodeForFieldType( * @param array $stack */ private function generateCodeForArray( - PropertyTypeArray $type, + PropertyTypeIterable $type, ?string $apiVersion, array $serializerGroups, string $arrayPath, @@ -221,11 +221,11 @@ private function generateCodeForArray( switch ($subType) { case $subType instanceof PropertyTypePrimitive: - case $subType instanceof PropertyTypeArray && self::isArrayForPrimitive($subType): + case $subType instanceof PropertyTypeIterable && self::isArrayForPrimitive($subType): case $subType instanceof PropertyTypeUnknown && $this->configuration->shouldAllowGenericArrays(): return $this->templating->renderArrayAssign($arrayPath, $modelPath); - case $subType instanceof PropertyTypeArray: + case $subType instanceof PropertyTypeIterable: $innerCode = $this->generateCodeForArray($subType, $apiVersion, $serializerGroups, $arrayPath.'['.$index.']', $modelPath.'['.$index.']', $stack); break; @@ -252,14 +252,14 @@ private function generateCodeForArray( return $this->templating->renderLoopArray($arrayPath, $modelPath, $index, $innerCode); } - private static function isArrayForPrimitive(PropertyTypeArray $type): bool + private static function isArrayForPrimitive(PropertyTypeIterable $type): bool { do { $type = $type->getSubType(); if ($type instanceof PropertyTypePrimitive) { return true; } - } while ($type instanceof PropertyTypeArray); + } while ($type instanceof PropertyTypeIterable); return false; } From f8e3f869a77da3fdd5c5dfe80b5a757770ee9e1e Mon Sep 17 00:00:00 2001 From: Martin Parsiegla Date: Fri, 18 Jul 2025 16:18:31 +0200 Subject: [PATCH 2/7] Add (JMS) discriminator support With this change, the `Discriminator` attribute from JMS is now fully supported. --- CHANGELOG.md | 3 ++ src/DeserializerGenerator.php | 51 +++++++++++++++++---- src/SerializerGenerator.php | 48 +++++++++++++++++-- src/Template/Deserialization.php | 22 ++++++++- src/Template/Serialization.php | 16 +++++++ tests/Fixtures/Discriminator.php | 17 +++++++ tests/Fixtures/DiscriminatorDependency.php | 10 ++++ tests/Fixtures/DiscriminatorFirstChild.php | 10 ++++ tests/Fixtures/DiscriminatorSecondChild.php | 10 ++++ tests/Unit/DeserializerGeneratorTest.php | 51 ++++++++++++++++++--- tests/Unit/SerializerGeneratorTest.php | 39 +++++++++++++--- 11 files changed, 248 insertions(+), 29 deletions(-) create mode 100644 tests/Fixtures/Discriminator.php create mode 100644 tests/Fixtures/DiscriminatorDependency.php create mode 100644 tests/Fixtures/DiscriminatorFirstChild.php create mode 100644 tests/Fixtures/DiscriminatorSecondChild.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a7d83a..e189c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ # 3.0.0 (unreleased) +* Update to liip/metadata-parser 2.x +* Add discriminator support + # 2.x # 2.6.2 diff --git a/src/DeserializerGenerator.php b/src/DeserializerGenerator.php index 0cc0ec6..2966bfc 100644 --- a/src/DeserializerGenerator.php +++ b/src/DeserializerGenerator.php @@ -4,6 +4,7 @@ namespace Liip\Serializer; +use Exception; use Liip\MetadataParser\Builder; use Liip\MetadataParser\Metadata\ClassMetadata; use Liip\MetadataParser\Metadata\PropertyMetadata; @@ -19,6 +20,9 @@ use Liip\Serializer\Path\ModelPath; use Liip\Serializer\Template\Deserialization; use Symfony\Component\Filesystem\Filesystem; +use function array_key_exists; +use function count; +use function is_string; final class DeserializerGenerator { @@ -63,8 +67,8 @@ public function generate(Builder $metadataBuilder): void private function writeFile(ClassMetadata $classMetadata): void { - if (\count($classMetadata->getConstructorParameters())) { - throw new \Exception(\sprintf('We currently do not support deserializing when the root class has a non-empty constructor. Class %s', $classMetadata->getClassName())); + if (count($classMetadata->getConstructorParameters())) { + throw new Exception(\sprintf('We currently do not support deserializing when the root class has a non-empty constructor. Class %s', $classMetadata->getClassName())); } $functionName = self::buildDeserializerFunctionName($classMetadata->getClassName()); @@ -89,6 +93,11 @@ private function generateCodeForClass( ModelPath $modelPath, array $stack = [], ): string { + $discriminatorMetadata = $classMetadata->getDiscriminatorMetadata(); + if (null !== $discriminatorMetadata && $discriminatorMetadata->baseClass == $classMetadata->getClassName()) { + return $this->generateCodeForDiscriminatorClass($classMetadata, $arrayPath, $modelPath, $stack); + } + $stack[$classMetadata->getClassName()] = ($stack[$classMetadata->getClassName()] ?? 0) + 1; $constructorArgumentNames = []; @@ -102,7 +111,7 @@ private function generateCodeForClass( $argument = $classMetadata->getConstructorParameter($propertyMetadata->getName()); $default = var_export($argument->isRequired() ? null : $argument->getDefaultValue(), true); $tempVariable = ModelPath::tempVariable([(string) $modelPath, $propertyMetadata->getName()]); - if (\array_key_exists($propertyMetadata->getName(), $constructorArgumentNames)) { + if (array_key_exists($propertyMetadata->getName(), $constructorArgumentNames)) { $overwrittenNames[$propertyMetadata->getName()] = true; } $constructorArgumentNames[$propertyMetadata->getName()] = (string) $tempVariable; @@ -123,7 +132,7 @@ private function generateCodeForClass( $constructorArguments = []; foreach ($classMetadata->getConstructorParameters() as $definition) { - if (\array_key_exists($definition->getName(), $constructorArgumentNames)) { + if (array_key_exists($definition->getName(), $constructorArgumentNames)) { $constructorArguments[] = $constructorArgumentNames[$definition->getName()]; continue; } @@ -132,17 +141,41 @@ private function generateCodeForClass( if ($overwrittenNames) { $msg .= \sprintf(' Multiple definitions for fields %s seen - the last one overwrites previous ones.', implode(', ', array_keys($overwrittenNames))); } - throw new \Exception($msg); + throw new Exception($msg); } $constructorArguments[] = var_export($definition->getDefaultValue(), true); } - if (\count($constructorArgumentNames) > 0) { + if (count($constructorArgumentNames) > 0) { $code .= $this->templating->renderUnset(array_values($constructorArgumentNames)); } return $this->templating->renderClass((string) $modelPath, $classMetadata->getClassName(), $constructorArguments, $code, $initCode); } + /** + * @param array $stack + */ + private function generateCodeForDiscriminatorClass( + ClassMetadata $classMetadata, + ArrayPath $arrayPath, + ModelPath $modelPath, + array $stack = [] + ): string + { + $code = ''; + $discriminatorMetadata = $classMetadata->getDiscriminatorMetadata(); + $discriminatorFieldPath = $arrayPath->withFieldName($discriminatorMetadata->propertyName); + foreach ($discriminatorMetadata->classMap as $typeValue => $class) { + $code .= $this->templating->renderDiscriminatorConditional( + (string)$discriminatorFieldPath, + $typeValue, + $this->generateCodeForClass($discriminatorMetadata->getMetadataForClass($class), $arrayPath, $modelPath, $stack) + ); + } + + return $code; + } + /** * @param array $stack */ @@ -212,7 +245,7 @@ private function generateInnerCodeForFieldType( return $this->generateCodeForArray($type, $arrayPath, $modelPropertyPath, $stack); case $type instanceof PropertyTypeDateTime: - $formats = $type->getDeserializeFormats() ?: (\is_string($type->getFormat()) ? [$type->getFormat()] : $type->getFormat()); + $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()); } @@ -230,7 +263,7 @@ private function generateInnerCodeForFieldType( return $this->generateCodeForClass($type->getClassMetadata(), $arrayPath, $modelPropertyPath, $stack); default: - throw new \Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); + throw new Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); } } @@ -266,7 +299,7 @@ private function generateCodeForArray( return $this->templating->renderAssignJsonDataToField((string) $modelPath, (string) $arrayPath); default: - throw new \Exception('Unexpected array subtype '.$subType::class); + throw new Exception('Unexpected array subtype '.$subType::class); } if ('' === $innerCode) { diff --git a/src/SerializerGenerator.php b/src/SerializerGenerator.php index 4203c8a..6a42af1 100644 --- a/src/SerializerGenerator.php +++ b/src/SerializerGenerator.php @@ -4,6 +4,8 @@ namespace Liip\Serializer; +use DateTimeInterface; +use Exception; use Liip\MetadataParser\Builder; use Liip\MetadataParser\Metadata\ClassMetadata; use Liip\MetadataParser\Metadata\PropertyMetadata; @@ -20,6 +22,7 @@ use Liip\Serializer\Configuration\GeneratorConfiguration; use Liip\Serializer\Template\Serialization; use Symfony\Component\Filesystem\Filesystem; +use function count; final class SerializerGenerator { @@ -41,7 +44,7 @@ public function __construct( public static function buildSerializerFunctionName(string $className, ?string $apiVersion, array $serializerGroups): string { $functionName = self::FILENAME_PREFIX.'_'.$className; - if (\count($serializerGroups)) { + if (count($serializerGroups)) { $functionName .= '_'.implode('_', $serializerGroups); } if (null !== $apiVersion) { @@ -120,6 +123,11 @@ private function generateCodeForClass( string $modelPath, array $stack = [], ): string { + $discriminatorMetadata = $classMetadata->getDiscriminatorMetadata(); + if (null !== $discriminatorMetadata && $discriminatorMetadata->baseClass == $classMetadata->getClassName()) { + return $this->generateCodeForDiscriminatorClass($classMetadata, $apiVersion, $serializerGroups, $arrayPath, $modelPath, $stack); + } + $stack[$classMetadata->getClassName()] = ($stack[$classMetadata->getClassName()] ?? 0) + 1; $code = ''; @@ -127,9 +135,39 @@ private function generateCodeForClass( $code .= $this->generateCodeForField($propertyMetadata, $apiVersion, $serializerGroups, $arrayPath, $modelPath, $stack); } + if (null !== $discriminatorMetadata) { + $discriminatorFieldPath = $arrayPath.'["'.$discriminatorMetadata->propertyName.'"]'; + $code .= $this->templating->renderAssign($discriminatorFieldPath, sprintf("'%s'", $discriminatorMetadata->value)); + } + return $this->templating->renderClass($arrayPath, $code); } + /** + * @param list $serializerGroups + * @param array $stack + */ + private function generateCodeForDiscriminatorClass( + ClassMetadata $classMetadata, + ?string $apiVersion, + array $serializerGroups, + string $arrayPath, + string $modelPath, + array $stack = [] + ): string { + $code = ''; + $discriminatorMetadata = $classMetadata->getDiscriminatorMetadata(); + foreach ($discriminatorMetadata->classMap as $class) { + $code .= $this->templating->renderInstanceOfConditional( + $modelPath, + $class, + $this->generateCodeForClass($discriminatorMetadata->getMetadataForClass($class), $apiVersion, $serializerGroups, $arrayPath, $modelPath, $stack) + ); + } + + return $code; + } + /** * @param list $serializerGroups * @param array $stack @@ -158,7 +196,7 @@ private function generateCodeForField( ); } if (!$propertyMetadata->isPublic()) { - throw new \Exception(\sprintf('Property %s is not public and no getter has been defined. Stack %s', $modelPropertyPath, var_export($stack, true))); + throw new Exception(\sprintf('Property %s is not public and no getter has been defined. Stack %s', $modelPropertyPath, var_export($stack, true))); } return $this->templating->renderConditional( @@ -181,7 +219,7 @@ private function generateCodeForFieldType( ): string { switch ($type) { case $type instanceof PropertyTypeDateTime: - $dateFormat = $type->getFormat() ?: \DateTimeInterface::ISO8601; + $dateFormat = $type->getFormat() ?: DateTimeInterface::ISO8601; return $this->templating->renderAssign( $fieldPath, @@ -200,7 +238,7 @@ private function generateCodeForFieldType( return $this->generateCodeForArray($type, $apiVersion, $serializerGroups, $fieldPath, $modelPropertyPath, $stack); default: - throw new \Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); + throw new Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); } } @@ -234,7 +272,7 @@ private function generateCodeForArray( break; default: - throw new \Exception('Unexpected array subtype '.$subType::class); + throw new Exception('Unexpected array subtype '.$subType::class); } if ('' === $innerCode) { diff --git a/src/Template/Deserialization.php b/src/Template/Deserialization.php index 4767e94..d211618 100644 --- a/src/Template/Deserialization.php +++ b/src/Template/Deserialization.php @@ -6,6 +6,8 @@ use Twig\Environment; use Twig\Loader\ArrayLoader; +use function is_string; +use const E_USER_DEPRECATED; final class Deserialization { @@ -44,6 +46,13 @@ function {{functionName}}(array {{jsonPath}}): {{className}} {{code}} } +EOT; + + private const TMPL_DISCRIMINATOR_CONDITIONAL = <<<'EOT' +if ({{jsonPath}} === '{{typeValue}}') { + {{code}} +} + EOT; private const TMPL_ASSIGN_JSON_DATA_TO_FIELD = <<<'EOT' @@ -185,6 +194,15 @@ public function renderConditional(string $data, string $code): string ]); } + public function renderDiscriminatorConditional(string $jsonPath, string $typeValue, string $code): string + { + return $this->render(self::TMPL_DISCRIMINATOR_CONDITIONAL, [ + 'jsonPath' => $jsonPath, + 'typeValue' => $typeValue, + 'code' => $code, + ]); + } + public function renderAssignJsonDataToField(string $modelPath, string $jsonPath): string { return $this->render(self::TMPL_ASSIGN_JSON_DATA_TO_FIELD, [ @@ -217,8 +235,8 @@ public function renderAssignDateTimeToField(bool $immutable, string $modelPath, */ 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); + 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]; } diff --git a/src/Template/Serialization.php b/src/Template/Serialization.php index 1e1bcd4..a519446 100644 --- a/src/Template/Serialization.php +++ b/src/Template/Serialization.php @@ -38,6 +38,13 @@ function {{functionName}}({{className}} $model, bool $useStdClass = true) {{code}} } +EOT; + + private const TMPL_INSTANCE_OF_CONDITIONAL = <<<'EOT' +if ({{propertyAccessor}} instanceof {{class}}) { + {{code}} +} + EOT; private const TMPL_ASSIGN = <<<'EOT' @@ -124,6 +131,15 @@ public function renderConditional(string $condition, string $code): string ]); } + public function renderInstanceOfConditional(string $propertyAccessor, string $class, string $code): string + { + return $this->render(self::TMPL_INSTANCE_OF_CONDITIONAL, [ + 'propertyAccessor' => $propertyAccessor, + 'class' => $class, + 'code' => $code, + ]); + } + public function renderAssign(string $jsonPath, string $propertyAccessor): string { return $this->render(self::TMPL_ASSIGN, [ diff --git a/tests/Fixtures/Discriminator.php b/tests/Fixtures/Discriminator.php new file mode 100644 index 0000000..ef16aed --- /dev/null +++ b/tests/Fixtures/Discriminator.php @@ -0,0 +1,17 @@ +unAnnotated); self::assertInstanceOf(Nested::class, $model->nestedField); self::assertSame('nested', $model->nestedField->nestedString); - self::assertInstanceOf(\DateTime::class, $model->date); + self::assertInstanceOf(DateTime::class, $model->date); self::assertSame('2018-08-03', $model->date->format('Y-m-d')); - self::assertInstanceOf(\DateTime::class, $model->dateWithFormat); + self::assertInstanceOf(DateTime::class, $model->dateWithFormat); self::assertSame('2018-08-04', $model->dateWithFormat->format('Y-m-d')); - self::assertInstanceOf(\DateTime::class, $model->dateWithOneDeserializationFormat); + self::assertInstanceOf(DateTime::class, $model->dateWithOneDeserializationFormat); self::assertSame('2019-05-15', $model->dateWithOneDeserializationFormat->format('Y-m-d')); - self::assertInstanceOf(\DateTime::class, $model->dateWithMultipleDeserializationFormats); + 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::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')); self::assertSame('2016-06-01', $model->getDateImmutablePrivate()?->format('Y-m-d')); } @@ -269,6 +275,37 @@ public function testVirtualProperties(): void self::assertSame('apiString', $model->apiString); } + public function testDiscriminator(): void + { + $functionName = 'deserialize_Tests_Liip_Serializer_Fixtures_DiscriminatorDependency'; + self::generateDeserializer(self::$metadataBuilder, DiscriminatorDependency::class, $functionName); + + + $input = [ + 'discriminator' => [ + 'first_property' => 'first-value', + 'type' => 'first', + ], + ]; + $model = $functionName($input); + + self::assertInstanceOf(DiscriminatorDependency::class, $model); + self::assertInstanceOf(DiscriminatorFirstChild::class, $model->discriminator); + self::assertSame('first-value', $model->discriminator->firstProperty); + + $input = [ + 'discriminator' => [ + 'second_property' => 'second-value', + 'type' => 'second', + ], + ]; + $model = $functionName($input); + + self::assertInstanceOf(DiscriminatorDependency::class, $model); + self::assertInstanceOf(DiscriminatorSecondChild::class, $model->discriminator); + self::assertSame('second-value', $model->discriminator->secondProperty); + } + public function testPostDeserialize(): void { $functionName = 'deserialize_Tests_Liip_Serializer_Fixtures_PostDeserialize'; diff --git a/tests/Unit/SerializerGeneratorTest.php b/tests/Unit/SerializerGeneratorTest.php index 6f926a8..c1d724b 100644 --- a/tests/Unit/SerializerGeneratorTest.php +++ b/tests/Unit/SerializerGeneratorTest.php @@ -4,15 +4,22 @@ namespace Tests\Liip\Serializer\Unit; +use DateTime; +use DateTimeImmutable; +use DateTimeZone; use Doctrine\Common\Annotations\AnnotationReader; use Doctrine\Common\Collections\ArrayCollection; +use Exception; use Liip\MetadataParser\Builder; use Liip\MetadataParser\ModelParser\JMSParser; use Liip\MetadataParser\ModelParser\PhpDocParser; use Liip\MetadataParser\ModelParser\ReflectionParser; +use stdClass; use Tests\Liip\Serializer\Fixtures\AccessorOrder; use Tests\Liip\Serializer\Fixtures\AccessorOrderInherit; use Tests\Liip\Serializer\Fixtures\ContainsPrivateProperty; +use Tests\Liip\Serializer\Fixtures\DiscriminatorDependency; +use Tests\Liip\Serializer\Fixtures\DiscriminatorFirstChild; use Tests\Liip\Serializer\Fixtures\InaccessiblePrivateProperty; use Tests\Liip\Serializer\Fixtures\Inheritance; use Tests\Liip\Serializer\Fixtures\ListModel; @@ -59,8 +66,8 @@ public function testGroups(): void $model->detailString = 'details'; $model->unAnnotated = 'unAnnotated'; $model->nestedField = new Nested('nested'); - $model->date = new \DateTime('2018-08-03', new \DateTimeZone('Europe/Zurich')); - $model->dateImmutable = new \DateTimeImmutable('2016-06-01', new \DateTimeZone('Europe/Zurich')); + $model->date = new DateTime('2018-08-03', new DateTimeZone('Europe/Zurich')); + $model->dateImmutable = new DateTimeImmutable('2016-06-01', new DateTimeZone('Europe/Zurich')); $expected = [ 'api_string' => 'api', @@ -206,7 +213,7 @@ public function testEmptyModel(): void $model = new Model(); $data = $functionName($model); - self::assertInstanceOf(\stdClass::class, $data); + self::assertInstanceOf(stdClass::class, $data); self::assertCount(0, get_object_vars($data)); } @@ -227,7 +234,7 @@ public function testDateTimeWithFormat(): void self::generateSerializers(self::$metadataBuilder, Model::class, [$functionName]); $model = new Model(); - $model->dateWithFormat = new \DateTime('2020-04-22 10:11:12'); + $model->dateWithFormat = new DateTime('2020-04-22 10:11:12'); $data = $functionName($model); self::assertSame(['date_with_format' => '2020-04-22'], $data); @@ -253,7 +260,7 @@ public function testEmptyArray(): void self::assertArrayHasKey('list_nested', $data); self::assertSame([], $data['list_nested']); self::assertArrayHasKey('hashmap', $data); - self::assertInstanceOf(\stdClass::class, $data['hashmap']); + self::assertInstanceOf(stdClass::class, $data['hashmap']); self::assertCount(0, get_object_vars($data['hashmap'])); } @@ -343,6 +350,26 @@ public function testVirtualProperties(): void self::assertSame($expected, $data); } + public function testDiscriminator(): void + { + $functionName = 'serialize_Tests_Liip_Serializer_Fixtures_DiscriminatorDependency_2'; + self::generateSerializers(self::$metadataBuilder, DiscriminatorDependency::class, [$functionName]); + + $model = new DiscriminatorDependency(); + $model->discriminator = new DiscriminatorFirstChild(); + $model->discriminator->firstProperty = 'my-value'; + + $expected = [ + 'discriminator' => [ + 'first_property' => 'my-value', + 'type' => 'first', + ], + ]; + $data = $functionName($model); + + self::assertSame($expected, $data); + } + /** * Assert that post deserialize is never executed during serialize. */ @@ -454,7 +481,7 @@ public function testVersioning(): void public function testInaccessibleProperty(): void { - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('is not public and no getter has been defined'); self::generateSerializers(self::$metadataBuilder, InaccessiblePrivateProperty::class, ['should never get here']); From 3f3cb63d23a7816f00b9e2378f3f0878a464ef59 Mon Sep 17 00:00:00 2001 From: Martin Parsiegla Date: Fri, 7 Nov 2025 16:18:11 +0100 Subject: [PATCH 3/7] Add support for union (discriminator attribute) With this change, it is now possible to use primitive union types (like `public int|bool $property`) or the more complex `#[UnionDiscriminator]` attribute from JMS. --- src/DeserializerGenerator.php | 70 ++++++++++++++++++++++-- src/SerializerGenerator.php | 57 ++++++++++++++++++- src/Template/Deserialization.php | 61 +++++++++++++++++++++ src/Template/Serialization.php | 36 ++++++++++++ tests/Fixtures/ComplexUnionTyping.php | 13 +++++ tests/Fixtures/DiscriminatorAuthor.php | 17 ++++++ tests/Fixtures/DiscriminatorComment.php | 22 ++++++++ tests/Fixtures/PrimitiveUnionTyping.php | 10 ++++ tests/Unit/DeserializerGeneratorTest.php | 65 +++++++++++++++++++++- tests/Unit/SerializerGeneratorTest.php | 70 ++++++++++++++++++++++++ 10 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 tests/Fixtures/ComplexUnionTyping.php create mode 100644 tests/Fixtures/DiscriminatorAuthor.php create mode 100644 tests/Fixtures/DiscriminatorComment.php create mode 100644 tests/Fixtures/PrimitiveUnionTyping.php diff --git a/src/DeserializerGenerator.php b/src/DeserializerGenerator.php index 2966bfc..b8f7801 100644 --- a/src/DeserializerGenerator.php +++ b/src/DeserializerGenerator.php @@ -8,10 +8,12 @@ use Liip\MetadataParser\Builder; use Liip\MetadataParser\Metadata\ClassMetadata; use Liip\MetadataParser\Metadata\PropertyMetadata; -use Liip\MetadataParser\Metadata\PropertyTypeIterable; +use Liip\MetadataParser\Metadata\PropertyType; use Liip\MetadataParser\Metadata\PropertyTypeClass; use Liip\MetadataParser\Metadata\PropertyTypeDateTime; +use Liip\MetadataParser\Metadata\PropertyTypeIterable; use Liip\MetadataParser\Metadata\PropertyTypePrimitive; +use Liip\MetadataParser\Metadata\PropertyTypeUnion; use Liip\MetadataParser\Metadata\PropertyTypeUnknown; use Liip\MetadataParser\Reducer\TakeBestReducer; use Liip\Serializer\Configuration\ClassToGenerate; @@ -23,6 +25,7 @@ use function array_key_exists; use function count; use function is_string; +use function sprintf; final class DeserializerGenerator { @@ -159,15 +162,14 @@ private function generateCodeForDiscriminatorClass( ClassMetadata $classMetadata, ArrayPath $arrayPath, ModelPath $modelPath, - array $stack = [] - ): string - { + array $stack = [], + ): string { $code = ''; $discriminatorMetadata = $classMetadata->getDiscriminatorMetadata(); $discriminatorFieldPath = $arrayPath->withFieldName($discriminatorMetadata->propertyName); foreach ($discriminatorMetadata->classMap as $typeValue => $class) { $code .= $this->templating->renderDiscriminatorConditional( - (string)$discriminatorFieldPath, + (string) $discriminatorFieldPath, $typeValue, $this->generateCodeForClass($discriminatorMetadata->getMetadataForClass($class), $arrayPath, $modelPath, $stack) ); @@ -262,11 +264,69 @@ private function generateInnerCodeForFieldType( case $type instanceof PropertyTypeClass: return $this->generateCodeForClass($type->getClassMetadata(), $arrayPath, $modelPropertyPath, $stack); + case $type instanceof PropertyTypeUnion: + return $this->generateCodeForUnion($type, $arrayPath, $modelPropertyPath, $stack); + default: throw new Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); } } + /** + * @param array $stack + */ + private function generateCodeForUnion( + PropertyTypeUnion $type, + ArrayPath $arrayPath, + ModelPath $modelPath, + array $stack, + ): string { + $code = ''; + + $types = $type->getTypes(); + $typesWithoutPrimitives = array_filter($types, static function (PropertyType $subType): bool { + return !($subType instanceof PropertyTypePrimitive || $subType instanceof PropertyTypeIterable); + }); + + $fieldName = $type->getFieldName(); + if (null !== $fieldName) { + $discriminatorFieldPath = $arrayPath->withFieldName($fieldName); + + foreach ($type->getTypeMap() as $typeValue => $class) { + $classType = $type->getTypeByClassName($class); + $code .= $this->templating->renderDiscriminatorConditional( + (string) $discriminatorFieldPath, + $typeValue, + $this->generateCodeForClass($classType->getClassMetadata(), $arrayPath, $modelPath, $stack) + ); + } + + return $code; + } + + if (0 !== count($typesWithoutPrimitives)) { + throw new Exception('Found union type that contains primitives and non primitives, which is currently not supported.'); + } + + $amountOfTypes = count($types); + foreach ($types as $key => $subType) { + $phpType = 'array'; + if ($subType instanceof PropertyTypePrimitive) { + $phpType = $subType->getTypeName(); + } + + $withElseBlock = $key !== ($amountOfTypes - 1); + $code .= $this->templating->renderPrimitiveConditional( + $phpType, + (string) $arrayPath, + $this->templating->renderAssignJsonDataToFieldWithCast($phpType, (string) $modelPath, (string) $arrayPath), + $withElseBlock + ); + } + + return $code; + } + /** * @param array $stack */ diff --git a/src/SerializerGenerator.php b/src/SerializerGenerator.php index 6a42af1..2fcc88c 100644 --- a/src/SerializerGenerator.php +++ b/src/SerializerGenerator.php @@ -14,6 +14,7 @@ use Liip\MetadataParser\Metadata\PropertyTypeDateTime; use Liip\MetadataParser\Metadata\PropertyTypeIterable; use Liip\MetadataParser\Metadata\PropertyTypePrimitive; +use Liip\MetadataParser\Metadata\PropertyTypeUnion; use Liip\MetadataParser\Metadata\PropertyTypeUnknown; use Liip\MetadataParser\Reducer\GroupReducer; use Liip\MetadataParser\Reducer\PreferredReducer; @@ -23,6 +24,7 @@ use Liip\Serializer\Template\Serialization; use Symfony\Component\Filesystem\Filesystem; use function count; +use function sprintf; final class SerializerGenerator { @@ -135,7 +137,7 @@ private function generateCodeForClass( $code .= $this->generateCodeForField($propertyMetadata, $apiVersion, $serializerGroups, $arrayPath, $modelPath, $stack); } - if (null !== $discriminatorMetadata) { + if (null !== $discriminatorMetadata) { $discriminatorFieldPath = $arrayPath.'["'.$discriminatorMetadata->propertyName.'"]'; $code .= $this->templating->renderAssign($discriminatorFieldPath, sprintf("'%s'", $discriminatorMetadata->value)); } @@ -153,7 +155,7 @@ private function generateCodeForDiscriminatorClass( array $serializerGroups, string $arrayPath, string $modelPath, - array $stack = [] + array $stack = [], ): string { $code = ''; $discriminatorMetadata = $classMetadata->getDiscriminatorMetadata(); @@ -237,6 +239,9 @@ private function generateCodeForFieldType( case $type instanceof PropertyTypeIterable: return $this->generateCodeForArray($type, $apiVersion, $serializerGroups, $fieldPath, $modelPropertyPath, $stack); + case $type instanceof PropertyTypeUnion: + return $this->generateCodeForUnion($type, $apiVersion, $serializerGroups, $fieldPath, $modelPropertyPath, $stack); + default: throw new Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); } @@ -290,6 +295,54 @@ private function generateCodeForArray( return $this->templating->renderLoopArray($arrayPath, $modelPath, $index, $innerCode); } + /** + * @param list $serializerGroups + * @param array $stack + */ + private function generateCodeForUnion( + PropertyTypeUnion $subType, + ?string $apiVersion, + array $serializerGroups, + string $arrayPath, + string $modelPath, + array $stack, + ): string { + $code = ''; + + $types = $subType->getTypes(); + $typesWithoutPrimitives = array_filter($types, static function (PropertyType $subType): bool { + return !($subType instanceof PropertyTypePrimitive || $subType instanceof PropertyTypeUnknown); + }); + + $hasPrimitives = count($types) !== count($typesWithoutPrimitives); + if ($hasPrimitives) { + $code .= $this->templating->renderPrimitiveConditional( + $modelPath, + $this->templating->renderAssign($arrayPath, $modelPath) + ); + } + + foreach ($typesWithoutPrimitives as $subType) { + switch ($subType::class) { + case PropertyTypeClass::class: + $code .= $this->templating->renderInstanceOfConditional( + $modelPath, + $subType->getClassName(), + $this->generateCodeForFieldType($subType, $apiVersion, $serializerGroups, $arrayPath, $modelPath, $stack) + ); + break; + case PropertyTypeIterable::class: + $code .= $this->templating->renderArrayConditional( + $modelPath, + $this->generateCodeForArray($subType, $apiVersion, $serializerGroups, $arrayPath, $modelPath, $stack) + ); + break; + } + } + + return $code; + } + private static function isArrayForPrimitive(PropertyTypeIterable $type): bool { do { diff --git a/src/Template/Deserialization.php b/src/Template/Deserialization.php index d211618..48b4e5b 100644 --- a/src/Template/Deserialization.php +++ b/src/Template/Deserialization.php @@ -4,13 +4,34 @@ namespace Liip\Serializer\Template; +use InvalidArgumentException; use Twig\Environment; use Twig\Loader\ArrayLoader; use function is_string; +use function sprintf; use const E_USER_DEPRECATED; final class Deserialization { + private const PRIMITIVE_CHECKS = [ + 'null' => 'is_null({{value}})', + 'array' => 'is_array({{value}})', + 'int' => '(string) (int) {{value}} === (string) {{value}}', + 'float' => '(string) (float) {{value}} === (string) {{value}}', + 'bool' => '!is_array({{value}}) && (string) (bool) {{value}} === (string) {{value}}', + 'true' => 'true === {{value}}', + 'false' => 'false === {{value}}', + 'string' => '!is_array({{value}}) && !is_object({{value}})', + ]; + + private const PRIMITIVE_CASTS = [ + 'array' => '{{value}}', + 'int' => '(int) {{value}}', + 'float' => '(float) {{value}}', + 'bool' => '(bool) {{value}}', + 'string' => '(string) {{value}}', + ]; + private const TMPL_FUNCTION = <<<'EOT' render($typeCheck, [ + 'value' => $jsonPath, + ]); + + return $this->render(self::TMPL_PRIMITIVE_CONDITIONAL, [ + 'typeConditional' => $typeConditional, + 'code' => $code, + 'withElseBlock' => $withElseBlock, + ]); + } + public function renderAssignJsonDataToField(string $modelPath, string $jsonPath): string { return $this->render(self::TMPL_ASSIGN_JSON_DATA_TO_FIELD, [ @@ -211,6 +257,21 @@ public function renderAssignJsonDataToField(string $modelPath, string $jsonPath) ]); } + public function renderAssignJsonDataToFieldWithCast(string $phpType, string $modelPath, string $jsonPath): string + { + $typeCast = self::PRIMITIVE_CASTS[$phpType] ?? null; + if (null !== $typeCast) { + $jsonPath = $this->render($typeCast, [ + 'value' => $jsonPath, + ]); + } + + return $this->render(self::TMPL_ASSIGN_JSON_DATA_TO_FIELD, [ + 'modelPath' => $modelPath, + 'jsonPath' => $jsonPath, + ]); + } + public function renderAssignJsonDataToFieldWithCasting(string $modelPath, string $jsonPath, string $type): string { return $this->render(self::TMPL_ASSIGN_JSON_DATA_TO_FIELD_CASTING, [ diff --git a/src/Template/Serialization.php b/src/Template/Serialization.php index a519446..3c94d83 100644 --- a/src/Template/Serialization.php +++ b/src/Template/Serialization.php @@ -16,6 +16,14 @@ function {{functionName}}({{className}} $model, bool $useStdClass = true) { $emptyHashmap = $useStdClass ? new \stdClass() : []; $emptyObject = $useStdClass ? new \stdClass() : []; + $isPrimitive = function (mixed $data) { + if (is_array($data)) { + return false; + } + + return null === $data || is_scalar($data); + }; + {{code}} @@ -45,6 +53,18 @@ function {{functionName}}({{className}} $model, bool $useStdClass = true) {{code}} } +EOT; + + private const TMPL_PRIMITIVE_CONDITIONAL = <<<'EOT' +if ($isPrimitive({{propertyAccessor}})) { + {{code}} +} +EOT; + + private const TMPL_ARRAY_CONDITIONAL = <<<'EOT' +if (is_array({{propertyAccessor}})) { + {{code}} +} EOT; private const TMPL_ASSIGN = <<<'EOT' @@ -140,6 +160,22 @@ public function renderInstanceOfConditional(string $propertyAccessor, string $cl ]); } + public function renderPrimitiveConditional(string $propertyAccessor, string $code): string + { + return $this->render(self::TMPL_PRIMITIVE_CONDITIONAL, [ + 'propertyAccessor' => $propertyAccessor, + 'code' => $code, + ]); + } + + public function renderArrayConditional(string $propertyAccessor, string $code): string + { + return $this->render(self::TMPL_ARRAY_CONDITIONAL, [ + 'propertyAccessor' => $propertyAccessor, + 'code' => $code, + ]); + } + public function renderAssign(string $jsonPath, string $propertyAccessor): string { return $this->render(self::TMPL_ASSIGN, [ diff --git a/tests/Fixtures/ComplexUnionTyping.php b/tests/Fixtures/ComplexUnionTyping.php new file mode 100644 index 0000000..0587fd1 --- /dev/null +++ b/tests/Fixtures/ComplexUnionTyping.php @@ -0,0 +1,13 @@ + DiscriminatorComment::class, 'author' => DiscriminatorAuthor::class])] + public DiscriminatorComment|DiscriminatorAuthor $property; +} diff --git a/tests/Fixtures/DiscriminatorAuthor.php b/tests/Fixtures/DiscriminatorAuthor.php new file mode 100644 index 0000000..3765560 --- /dev/null +++ b/tests/Fixtures/DiscriminatorAuthor.php @@ -0,0 +1,17 @@ +objectType; + } +} diff --git a/tests/Fixtures/PrimitiveUnionTyping.php b/tests/Fixtures/PrimitiveUnionTyping.php new file mode 100644 index 0000000..ee13e61 --- /dev/null +++ b/tests/Fixtures/PrimitiveUnionTyping.php @@ -0,0 +1,10 @@ + [ 'first_property' => 'first-value', @@ -306,6 +309,66 @@ public function testDiscriminator(): void self::assertSame('second-value', $model->discriminator->secondProperty); } + public function testComplexUnionDiscriminator(): void + { + if (\PHP_VERSION_ID < 80100) { + self::markTestSkipped('Intersection property types are only supported in PHP 8.1 or newer'); + } + + $functionName = 'deserialize_Tests_Liip_Serializer_Fixtures_ComplexUnionTyping'; + self::generateDeserializer(self::$metadataBuilder, ComplexUnionTyping::class, $functionName); + + $input = [ + 'property' => [ + 'text' => 'my-text', + 'objectType' => 'comment', + ], + ]; + $model = $functionName($input); + + self::assertInstanceOf(ComplexUnionTyping::class, $model); + self::assertInstanceOf(DiscriminatorComment::class, $model->property); + self::assertSame('my-text', $model->property->text); + + $input = [ + 'property' => [ + 'name' => 'my-author', + 'objectType' => 'author', + ], + ]; + $model = $functionName($input); + + self::assertInstanceOf(ComplexUnionTyping::class, $model); + self::assertInstanceOf(DiscriminatorAuthor::class, $model->property); + self::assertSame('my-author', $model->property->name); + } + + /** + * @dataProvider providePrimitiveUnionDiscriminatorCases + */ + public function testPrimitiveUnionDiscriminator(mixed $propertyValue): void + { + $functionName = 'deserialize_Tests_Liip_Serializer_Fixtures_PrimitiveUnionTyping'; + self::generateDeserializer(self::$metadataBuilder, PrimitiveUnionTyping::class, $functionName, ['allow_generic_arrays' => true]); + + $input = ['property' => $propertyValue]; + $model = $functionName($input); + + self::assertInstanceOf(PrimitiveUnionTyping::class, $model); + self::assertSame($propertyValue, $model->property); + } + + public static function providePrimitiveUnionDiscriminatorCases(): iterable + { + return [ + [42], + ['string-value'], + [false], + [0.5], + [['key' => 'value', 'another_key' => 'another_value']], + ]; + } + public function testPostDeserialize(): void { $functionName = 'deserialize_Tests_Liip_Serializer_Fixtures_PostDeserialize'; diff --git a/tests/Unit/SerializerGeneratorTest.php b/tests/Unit/SerializerGeneratorTest.php index c1d724b..7630555 100644 --- a/tests/Unit/SerializerGeneratorTest.php +++ b/tests/Unit/SerializerGeneratorTest.php @@ -17,7 +17,10 @@ use stdClass; use Tests\Liip\Serializer\Fixtures\AccessorOrder; use Tests\Liip\Serializer\Fixtures\AccessorOrderInherit; +use Tests\Liip\Serializer\Fixtures\ComplexUnionTyping; use Tests\Liip\Serializer\Fixtures\ContainsPrivateProperty; +use Tests\Liip\Serializer\Fixtures\DiscriminatorAuthor; +use Tests\Liip\Serializer\Fixtures\DiscriminatorComment; use Tests\Liip\Serializer\Fixtures\DiscriminatorDependency; use Tests\Liip\Serializer\Fixtures\DiscriminatorFirstChild; use Tests\Liip\Serializer\Fixtures\InaccessiblePrivateProperty; @@ -27,6 +30,7 @@ use Tests\Liip\Serializer\Fixtures\MultidimensionalArrayForPrimitive; use Tests\Liip\Serializer\Fixtures\Nested; use Tests\Liip\Serializer\Fixtures\PostDeserialize; +use Tests\Liip\Serializer\Fixtures\PrimitiveUnionTyping; use Tests\Liip\Serializer\Fixtures\PrivateProperty; use Tests\Liip\Serializer\Fixtures\RecursionModel; use Tests\Liip\Serializer\Fixtures\UnknownArraySubType; @@ -370,6 +374,72 @@ public function testDiscriminator(): void self::assertSame($expected, $data); } + public function testComplexUnionDiscriminator(): void + { + if (\PHP_VERSION_ID < 80100) { + self::markTestSkipped('Intersection property types are only supported in PHP 8.1 or newer'); + } + + $functionName = 'serialize_Tests_Liip_Serializer_Fixtures_ComplexUnionTyping_2'; + self::generateSerializers(self::$metadataBuilder, ComplexUnionTyping::class, [$functionName]); + + $model = new ComplexUnionTyping(); + $model->property = new DiscriminatorAuthor(); + $model->property->name = 'author-name'; + + $expected = [ + 'property' => [ + 'name' => 'author-name', + 'objectType' => 'author', + ], + ]; + $data = $functionName($model); + + self::assertSame($expected, $data); + + $model = new ComplexUnionTyping(); + $model->property = new DiscriminatorComment(); + $model->property->text = 'comment-text'; + + $expected = [ + 'property' => [ + 'text' => 'comment-text', + 'objectType' => 'comment', + ], + ]; + $data = $functionName($model); + + self::assertSame($expected, $data); + } + + /** + * @dataProvider providePrimitiveUnionDiscriminatorCases + */ + public function testPrimitiveUnionDiscriminator(mixed $propertyValue): void + { + $functionName = 'serialize_Tests_Liip_Serializer_Fixtures_PrimitiveUnionTyping'; + self::generateSerializers(self::$metadataBuilder, PrimitiveUnionTyping::class, [$functionName], [''], [], ['allow_generic_arrays' => true]); + + $model = new PrimitiveUnionTyping(); + $model->property = $propertyValue; + + $expected = ['property' => $propertyValue]; + $data = $functionName($model); + + self::assertSame($expected, $data); + } + + public static function providePrimitiveUnionDiscriminatorCases(): iterable + { + return [ + [42], + ['string-value'], + [false], + [0.5], + [['key' => 'value', 'another_key' => 'another_value']], + ]; + } + /** * Assert that post deserialize is never executed during serialize. */ From ea63501c228db5010d40b1998e585db620d3337b Mon Sep 17 00:00:00 2001 From: Martin Parsiegla Date: Fri, 28 Nov 2025 14:11:54 +0100 Subject: [PATCH 4/7] Update php-cs-fixer and apply fixes --- composer.json | 2 +- src/DeserializerGenerator.php | 29 ++++++++++-------------- src/Recursion.php | 2 +- src/SerializerGenerator.php | 18 ++++++--------- src/Template/Deserialization.php | 10 +++----- tests/Unit/DeserializerGeneratorTest.php | 17 ++++++-------- tests/Unit/SerializerGeneratorTest.php | 17 +++++--------- 7 files changed, 37 insertions(+), 58 deletions(-) diff --git a/composer.json b/composer.json index 6dc57e6..f725ab3 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ }, "require-dev": { "doctrine/collections": "^1.6", - "friendsofphp/php-cs-fixer": "^3.23", + "friendsofphp/php-cs-fixer": "^3.90.0", "jms/serializer": "^1.13 || ^2 || ^3", "phpstan/phpstan": "^1.0", "phpstan/phpstan-phpunit": "^1.3", diff --git a/src/DeserializerGenerator.php b/src/DeserializerGenerator.php index b8f7801..ff8bd77 100644 --- a/src/DeserializerGenerator.php +++ b/src/DeserializerGenerator.php @@ -4,7 +4,6 @@ namespace Liip\Serializer; -use Exception; use Liip\MetadataParser\Builder; use Liip\MetadataParser\Metadata\ClassMetadata; use Liip\MetadataParser\Metadata\PropertyMetadata; @@ -22,10 +21,6 @@ use Liip\Serializer\Path\ModelPath; use Liip\Serializer\Template\Deserialization; use Symfony\Component\Filesystem\Filesystem; -use function array_key_exists; -use function count; -use function is_string; -use function sprintf; final class DeserializerGenerator { @@ -70,8 +65,8 @@ public function generate(Builder $metadataBuilder): void private function writeFile(ClassMetadata $classMetadata): void { - if (count($classMetadata->getConstructorParameters())) { - throw new Exception(\sprintf('We currently do not support deserializing when the root class has a non-empty constructor. Class %s', $classMetadata->getClassName())); + if (\count($classMetadata->getConstructorParameters())) { + throw new \Exception(\sprintf('We currently do not support deserializing when the root class has a non-empty constructor. Class %s', $classMetadata->getClassName())); } $functionName = self::buildDeserializerFunctionName($classMetadata->getClassName()); @@ -114,7 +109,7 @@ private function generateCodeForClass( $argument = $classMetadata->getConstructorParameter($propertyMetadata->getName()); $default = var_export($argument->isRequired() ? null : $argument->getDefaultValue(), true); $tempVariable = ModelPath::tempVariable([(string) $modelPath, $propertyMetadata->getName()]); - if (array_key_exists($propertyMetadata->getName(), $constructorArgumentNames)) { + if (\array_key_exists($propertyMetadata->getName(), $constructorArgumentNames)) { $overwrittenNames[$propertyMetadata->getName()] = true; } $constructorArgumentNames[$propertyMetadata->getName()] = (string) $tempVariable; @@ -135,7 +130,7 @@ private function generateCodeForClass( $constructorArguments = []; foreach ($classMetadata->getConstructorParameters() as $definition) { - if (array_key_exists($definition->getName(), $constructorArgumentNames)) { + if (\array_key_exists($definition->getName(), $constructorArgumentNames)) { $constructorArguments[] = $constructorArgumentNames[$definition->getName()]; continue; } @@ -144,11 +139,11 @@ private function generateCodeForClass( if ($overwrittenNames) { $msg .= \sprintf(' Multiple definitions for fields %s seen - the last one overwrites previous ones.', implode(', ', array_keys($overwrittenNames))); } - throw new Exception($msg); + throw new \Exception($msg); } $constructorArguments[] = var_export($definition->getDefaultValue(), true); } - if (count($constructorArgumentNames) > 0) { + if (\count($constructorArgumentNames) > 0) { $code .= $this->templating->renderUnset(array_values($constructorArgumentNames)); } @@ -247,7 +242,7 @@ private function generateInnerCodeForFieldType( return $this->generateCodeForArray($type, $arrayPath, $modelPropertyPath, $stack); case $type instanceof PropertyTypeDateTime: - $formats = $type->getDeserializeFormats() ?: (is_string($type->getFormat()) ? [$type->getFormat()] : $type->getFormat()); + $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()); } @@ -268,7 +263,7 @@ private function generateInnerCodeForFieldType( return $this->generateCodeForUnion($type, $arrayPath, $modelPropertyPath, $stack); default: - throw new Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); + throw new \Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); } } @@ -304,11 +299,11 @@ private function generateCodeForUnion( return $code; } - if (0 !== count($typesWithoutPrimitives)) { - throw new Exception('Found union type that contains primitives and non primitives, which is currently not supported.'); + if (0 !== \count($typesWithoutPrimitives)) { + throw new \Exception('Found union type that contains primitives and non primitives, which is currently not supported.'); } - $amountOfTypes = count($types); + $amountOfTypes = \count($types); foreach ($types as $key => $subType) { $phpType = 'array'; if ($subType instanceof PropertyTypePrimitive) { @@ -359,7 +354,7 @@ private function generateCodeForArray( return $this->templating->renderAssignJsonDataToField((string) $modelPath, (string) $arrayPath); default: - throw new Exception('Unexpected array subtype '.$subType::class); + throw new \Exception('Unexpected array subtype '.$subType::class); } if ('' === $innerCode) { diff --git a/src/Recursion.php b/src/Recursion.php index ce46425..e6fab3e 100644 --- a/src/Recursion.php +++ b/src/Recursion.php @@ -5,8 +5,8 @@ namespace Liip\Serializer; use Liip\MetadataParser\Metadata\PropertyMetadata; -use Liip\MetadataParser\Metadata\PropertyTypeIterable; use Liip\MetadataParser\Metadata\PropertyTypeClass; +use Liip\MetadataParser\Metadata\PropertyTypeIterable; abstract class Recursion { diff --git a/src/SerializerGenerator.php b/src/SerializerGenerator.php index 2fcc88c..8adcfba 100644 --- a/src/SerializerGenerator.php +++ b/src/SerializerGenerator.php @@ -4,8 +4,6 @@ namespace Liip\Serializer; -use DateTimeInterface; -use Exception; use Liip\MetadataParser\Builder; use Liip\MetadataParser\Metadata\ClassMetadata; use Liip\MetadataParser\Metadata\PropertyMetadata; @@ -23,8 +21,6 @@ use Liip\Serializer\Configuration\GeneratorConfiguration; use Liip\Serializer\Template\Serialization; use Symfony\Component\Filesystem\Filesystem; -use function count; -use function sprintf; final class SerializerGenerator { @@ -46,7 +42,7 @@ public function __construct( public static function buildSerializerFunctionName(string $className, ?string $apiVersion, array $serializerGroups): string { $functionName = self::FILENAME_PREFIX.'_'.$className; - if (count($serializerGroups)) { + if (\count($serializerGroups)) { $functionName .= '_'.implode('_', $serializerGroups); } if (null !== $apiVersion) { @@ -139,7 +135,7 @@ private function generateCodeForClass( if (null !== $discriminatorMetadata) { $discriminatorFieldPath = $arrayPath.'["'.$discriminatorMetadata->propertyName.'"]'; - $code .= $this->templating->renderAssign($discriminatorFieldPath, sprintf("'%s'", $discriminatorMetadata->value)); + $code .= $this->templating->renderAssign($discriminatorFieldPath, \sprintf("'%s'", $discriminatorMetadata->value)); } return $this->templating->renderClass($arrayPath, $code); @@ -198,7 +194,7 @@ private function generateCodeForField( ); } if (!$propertyMetadata->isPublic()) { - throw new Exception(\sprintf('Property %s is not public and no getter has been defined. Stack %s', $modelPropertyPath, var_export($stack, true))); + throw new \Exception(\sprintf('Property %s is not public and no getter has been defined. Stack %s', $modelPropertyPath, var_export($stack, true))); } return $this->templating->renderConditional( @@ -221,7 +217,7 @@ private function generateCodeForFieldType( ): string { switch ($type) { case $type instanceof PropertyTypeDateTime: - $dateFormat = $type->getFormat() ?: DateTimeInterface::ISO8601; + $dateFormat = $type->getFormat() ?: \DateTimeInterface::ISO8601; return $this->templating->renderAssign( $fieldPath, @@ -243,7 +239,7 @@ private function generateCodeForFieldType( return $this->generateCodeForUnion($type, $apiVersion, $serializerGroups, $fieldPath, $modelPropertyPath, $stack); default: - throw new Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); + throw new \Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); } } @@ -277,7 +273,7 @@ private function generateCodeForArray( break; default: - throw new Exception('Unexpected array subtype '.$subType::class); + throw new \Exception('Unexpected array subtype '.$subType::class); } if ('' === $innerCode) { @@ -314,7 +310,7 @@ private function generateCodeForUnion( return !($subType instanceof PropertyTypePrimitive || $subType instanceof PropertyTypeUnknown); }); - $hasPrimitives = count($types) !== count($typesWithoutPrimitives); + $hasPrimitives = \count($types) !== \count($typesWithoutPrimitives); if ($hasPrimitives) { $code .= $this->templating->renderPrimitiveConditional( $modelPath, diff --git a/src/Template/Deserialization.php b/src/Template/Deserialization.php index 48b4e5b..9459481 100644 --- a/src/Template/Deserialization.php +++ b/src/Template/Deserialization.php @@ -4,12 +4,8 @@ namespace Liip\Serializer\Template; -use InvalidArgumentException; use Twig\Environment; use Twig\Loader\ArrayLoader; -use function is_string; -use function sprintf; -use const E_USER_DEPRECATED; final class Deserialization { @@ -235,7 +231,7 @@ public function renderPrimitiveConditional(string $phpType, string $jsonPath, st { $typeCheck = self::PRIMITIVE_CHECKS[$phpType] ?? null; if (null === $typeCheck) { - throw new InvalidArgumentException(sprintf('Provided type "%s" but only the following types are supported: %s', $phpType, implode(', ', array_keys(self::PRIMITIVE_CHECKS)))); + throw new \InvalidArgumentException(\sprintf('Provided type "%s" but only the following types are supported: %s', $phpType, implode(', ', array_keys(self::PRIMITIVE_CHECKS)))); } $typeConditional = $this->render($typeCheck, [ @@ -296,8 +292,8 @@ public function renderAssignDateTimeToField(bool $immutable, string $modelPath, */ 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); + 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]; } diff --git a/tests/Unit/DeserializerGeneratorTest.php b/tests/Unit/DeserializerGeneratorTest.php index 2438a9f..51dfd33 100644 --- a/tests/Unit/DeserializerGeneratorTest.php +++ b/tests/Unit/DeserializerGeneratorTest.php @@ -4,9 +4,6 @@ namespace Tests\Liip\Serializer\Unit; -use DateTime; -use DateTimeImmutable; -use DateTimeZone; use Doctrine\Common\Annotations\AnnotationReader; use Doctrine\Common\Collections\ArrayCollection; use Liip\MetadataParser\Builder; @@ -77,17 +74,17 @@ public function testNested(): void self::assertNull($model->unAnnotated); self::assertInstanceOf(Nested::class, $model->nestedField); self::assertSame('nested', $model->nestedField->nestedString); - self::assertInstanceOf(DateTime::class, $model->date); + self::assertInstanceOf(\DateTime::class, $model->date); self::assertSame('2018-08-03', $model->date->format('Y-m-d')); - self::assertInstanceOf(DateTime::class, $model->dateWithFormat); + self::assertInstanceOf(\DateTime::class, $model->dateWithFormat); self::assertSame('2018-08-04', $model->dateWithFormat->format('Y-m-d')); - self::assertInstanceOf(DateTime::class, $model->dateWithOneDeserializationFormat); + self::assertInstanceOf(\DateTime::class, $model->dateWithOneDeserializationFormat); self::assertSame('2019-05-15', $model->dateWithOneDeserializationFormat->format('Y-m-d')); - self::assertInstanceOf(DateTime::class, $model->dateWithMultipleDeserializationFormats); + 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::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')); self::assertSame('2016-06-01', $model->getDateImmutablePrivate()?->format('Y-m-d')); } diff --git a/tests/Unit/SerializerGeneratorTest.php b/tests/Unit/SerializerGeneratorTest.php index 7630555..68ac976 100644 --- a/tests/Unit/SerializerGeneratorTest.php +++ b/tests/Unit/SerializerGeneratorTest.php @@ -4,17 +4,12 @@ namespace Tests\Liip\Serializer\Unit; -use DateTime; -use DateTimeImmutable; -use DateTimeZone; use Doctrine\Common\Annotations\AnnotationReader; use Doctrine\Common\Collections\ArrayCollection; -use Exception; use Liip\MetadataParser\Builder; use Liip\MetadataParser\ModelParser\JMSParser; use Liip\MetadataParser\ModelParser\PhpDocParser; use Liip\MetadataParser\ModelParser\ReflectionParser; -use stdClass; use Tests\Liip\Serializer\Fixtures\AccessorOrder; use Tests\Liip\Serializer\Fixtures\AccessorOrderInherit; use Tests\Liip\Serializer\Fixtures\ComplexUnionTyping; @@ -70,8 +65,8 @@ public function testGroups(): void $model->detailString = 'details'; $model->unAnnotated = 'unAnnotated'; $model->nestedField = new Nested('nested'); - $model->date = new DateTime('2018-08-03', new DateTimeZone('Europe/Zurich')); - $model->dateImmutable = new DateTimeImmutable('2016-06-01', new DateTimeZone('Europe/Zurich')); + $model->date = new \DateTime('2018-08-03', new \DateTimeZone('Europe/Zurich')); + $model->dateImmutable = new \DateTimeImmutable('2016-06-01', new \DateTimeZone('Europe/Zurich')); $expected = [ 'api_string' => 'api', @@ -217,7 +212,7 @@ public function testEmptyModel(): void $model = new Model(); $data = $functionName($model); - self::assertInstanceOf(stdClass::class, $data); + self::assertInstanceOf(\stdClass::class, $data); self::assertCount(0, get_object_vars($data)); } @@ -238,7 +233,7 @@ public function testDateTimeWithFormat(): void self::generateSerializers(self::$metadataBuilder, Model::class, [$functionName]); $model = new Model(); - $model->dateWithFormat = new DateTime('2020-04-22 10:11:12'); + $model->dateWithFormat = new \DateTime('2020-04-22 10:11:12'); $data = $functionName($model); self::assertSame(['date_with_format' => '2020-04-22'], $data); @@ -264,7 +259,7 @@ public function testEmptyArray(): void self::assertArrayHasKey('list_nested', $data); self::assertSame([], $data['list_nested']); self::assertArrayHasKey('hashmap', $data); - self::assertInstanceOf(stdClass::class, $data['hashmap']); + self::assertInstanceOf(\stdClass::class, $data['hashmap']); self::assertCount(0, get_object_vars($data['hashmap'])); } @@ -551,7 +546,7 @@ public function testVersioning(): void public function testInaccessibleProperty(): void { - $this->expectException(Exception::class); + $this->expectException(\Exception::class); $this->expectExceptionMessage('is not public and no getter has been defined'); self::generateSerializers(self::$metadataBuilder, InaccessiblePrivateProperty::class, ['should never get here']); From c8c36b2247ae3d9a8731438c33dc692d651e15ef Mon Sep 17 00:00:00 2001 From: Martin Parsiegla Date: Fri, 28 Nov 2025 14:50:20 +0100 Subject: [PATCH 5/7] Adjust README to reflect renamed file --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 37123dd..f645f8d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ use Liip\MetadataParser\Builder; use Liip\MetadataParser\Parser; use Liip\MetadataParser\RecursionChecker; use Liip\MetadataParser\ModelParser\JMSParser; -use Liip\MetadataParser\ModelParser\LiipMetadataAnnotationParser; +use Liip\MetadataParser\ModelParser\LiipMetadataAttributeParser; use Liip\MetadataParser\ModelParser\PhpDocParser; use Liip\MetadataParser\ModelParser\ReflectionParser; use Liip\Serializer\DeserializerGenerator; @@ -75,7 +75,7 @@ $parsers = [ new ReflectionParser(), new PhpDocParser(), new JMSParser(new AnnotationReader()), - new LiipMetadataAnnotationParser(new AnnotationReader()), + new LiipMetadataAttributeParser(), ]; $builder = new Builder(new Parser($parsers), new RecursionChecker(null, [])); From 082588390fbe36872e959c6c22cdd4c5459dc344 Mon Sep 17 00:00:00 2001 From: Martin Parsiegla Date: Mon, 1 Dec 2025 14:23:19 +0100 Subject: [PATCH 6/7] Remove deprecated string type for `formats` argument Passing a `string` for the `$formats` argument when rendering the assignment of a `DateTime` class was deprecated in `2.x` and thus got for `3.x`. --- src/Template/Deserialization.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Template/Deserialization.php b/src/Template/Deserialization.php index 9459481..2a4a7a5 100644 --- a/src/Template/Deserialization.php +++ b/src/Template/Deserialization.php @@ -288,15 +288,10 @@ public function renderAssignDateTimeToField(bool $immutable, string $modelPath, } /** - * @param list|string $formats + * @param list $formats */ - public function renderAssignDateTimeFromFormat(bool $immutable, string $modelPath, string $jsonPath, array|string $formats, ?string $timezone = null): string + public function renderAssignDateTimeFromFormat(bool $immutable, string $modelPath, string $jsonPath, array $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), From e2521f7fc24761ef04c2bdb1d150f1abfb04460f Mon Sep 17 00:00:00 2001 From: Martin Parsiegla Date: Mon, 1 Dec 2025 18:05:35 +0100 Subject: [PATCH 7/7] Mention some BC breaks for `metadata-parser` Those are the more critical BC breaks, so its worth mentioning them here as well. For all further changes, the `CHANGELOG.md` from the `metadata-parser` is also referenced. --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e189c00..c21e995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ # 3.0.0 (unreleased) * Update to liip/metadata-parser 2.x + * The `LiipMetadataAnnotationParser` got renamed to `LiipMetadataAttributeParser` which means the `@Preferred` + annotation is no longer allowed and has to be replaced by the `#[Preferred]` attribute. + * Setting a (different) naming strategy via the `PropertyCollection::useIdenticalNamingStrategy` is no longer + possible, instead the naming strategy now has to be passed as the second argument when creating a new instance + of the `Parser` class + * For further changes in this library, take a look at the + [changelog from metadata-parser](https://github.com/liip/metadata-parser/blob/2.x/CHANGELOG.md) * Add discriminator support # 2.x