diff --git a/README.md b/README.md index 7dd4bbf..72ca914 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ resolving steps using [code generation](#maximizing-performance). - [**Creating your own property casters**](#creating-your-own-property-casters) - [**Static constructors**](#static-constructors) - [**Key formatters**](#key-formatters) + - [**Serializing maps as objects**](#serializing-maps-as-objects) - [**Maximizing performance**](#maximizing-performance) ## Design goals @@ -598,6 +599,59 @@ $payload = $mapper->serializeObject(new Shout('Hello, World!'); $payload['what'] === 'HELLO, WORLD!'; ``` +### Serializing maps as objects +By default, associative arrays are serialized as arrays in the payload. Since associative arrays represent +unstructured key-value maps that are typically treated as objects in many data formats, you may want to serialize +them as objects for better cross-platform compatibility. + +You can configure the mapper to serialize associative arrays as objects by enabling the `serializeMapsAsObjects` option: + +```php +use EventSauce\ObjectHydrator\DefinitionProvider; +use EventSauce\ObjectHydrator\ObjectMapperUsingReflection; + +$mapper = new ObjectMapperUsingReflection( + new DefinitionProvider(serializeMapsAsObjects: true), +); +``` + +Maps are serialized as objects based on their doc-comment type hints. Arrays with `array` type hints +are treated as maps and serialized as objects. + +```php +class ExampleCommand +{ + /** + * @param array $changedFields + */ + public function __construct( + public readonly array $changedFields, + ) {} + + /** + * @return array + */ + public function metadata(): array + { + return [ + 'source' => 'api', + ]; + } +} + +$command = new ExampleCommand(['email' => 'new@example.com', 'name' => 'John']); + +$payload = $mapper->serializeObject($command); +``` + +Serialized payload: +``` +[ + 'changed_fields' => {"email": "new@example.com", "name": "John"}, + 'metadata' => {"source": "api"} +] +``` + ## Symmetrical conversion If configured consistently, hydration and serialization can be used to translate an object to raw data diff --git a/composer.json b/composer.json index aa7b3d8..666510a 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,8 @@ "license": "MIT", "require": { "php": "^8.0", - "ext-fileinfo": "*" + "ext-fileinfo": "*", + "symfony/polyfill-php81": "^1.3" }, "require-dev": { "phpunit/phpunit": "^9.5.11", diff --git a/composer.lock b/composer.lock index 1ae1501..27d4e48 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,89 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "41a3dedd382a314d8c984d05ce7b77d8", - "packages": [], + "content-hash": "b721142c886b19f0b050e7292e2577d3", + "packages": [ + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + } + ], "packages-dev": [ { "name": "brick/math", @@ -4553,82 +4634,6 @@ ], "time": "2024-09-09T11:45:10+00:00" }, - { - "name": "symfony/polyfill-php81", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, { "name": "symfony/process", "version": "v7.2.0", diff --git a/src/ConcreteType.php b/src/ConcreteType.php index d725336..39d9ab3 100644 --- a/src/ConcreteType.php +++ b/src/ConcreteType.php @@ -14,6 +14,8 @@ */ final class ConcreteType { + public bool $associative = false; + public function __construct(public string $name, public bool $isBuiltIn) { } diff --git a/src/DefinitionProvider.php b/src/DefinitionProvider.php index 1b06f4f..d3e8a01 100644 --- a/src/DefinitionProvider.php +++ b/src/DefinitionProvider.php @@ -146,6 +146,7 @@ private function stringifyConstructor(ReflectionMethod $constructor): string public function provideSerializationDefinition(string $className): ClassSerializationDefinition { $reflection = new ReflectionClass($className); + $constructor = $this->constructorResolver->resolveConstructor($reflection); $objectSettings = $this->resolveObjectSettings($reflection); $classAttributes = $reflection->getAttributes(); $properties = []; @@ -173,7 +174,7 @@ public function provideSerializationDefinition(string $className): ClassSerializ PropertySerializationDefinition::TYPE_METHOD, $methodName, $this->resolveSerializers($returnType, $attributes), - PropertyType::fromReflectionType($returnType), + $this->propertyTypeResolver->typeFromMethod($method), $returnType->allowsNull(), $this->resolveKeys($key, $attributes), $typeSpecifier?->key, @@ -204,7 +205,7 @@ public function provideSerializationDefinition(string $className): ClassSerializ PropertySerializationDefinition::TYPE_PROPERTY, $property->getName(), $serializers, - PropertyType::fromReflectionType($propertyType), + $this->propertyTypeResolver->typeFromProperty($property, $constructor), $propertyType->allowsNull(), $this->resolveKeys($key, $attributes), $typeSpecifier?->key, diff --git a/src/Fixtures/ClassThatHasMultipleCastersOnMapProperty.php b/src/Fixtures/ClassThatHasMultipleCastersOnMapProperty.php new file mode 100644 index 0000000..31c4b1f --- /dev/null +++ b/src/Fixtures/ClassThatHasMultipleCastersOnMapProperty.php @@ -0,0 +1,22 @@ +> $map + */ + public function __construct( + #[CastToType('array')] + #[CastToArrayWithKey('second_level')] + #[CastToArrayWithKey('first_level')] + public array $map, + ) { + } +} \ No newline at end of file diff --git a/src/Fixtures/ClassThatSpecifiesArrayWithIntegerKeys.php b/src/Fixtures/ClassThatSpecifiesArrayWithIntegerKeys.php new file mode 100644 index 0000000..3e58455 --- /dev/null +++ b/src/Fixtures/ClassThatSpecifiesArrayWithIntegerKeys.php @@ -0,0 +1,16 @@ + $arrayWithIntegerKeys + */ + public function __construct( + public array $arrayWithIntegerKeys, + ) { + } +} \ No newline at end of file diff --git a/src/Fixtures/ClassThatSpecifiesArraysWithDocComments.php b/src/Fixtures/ClassThatSpecifiesArraysWithDocComments.php new file mode 100644 index 0000000..a6792c5 --- /dev/null +++ b/src/Fixtures/ClassThatSpecifiesArraysWithDocComments.php @@ -0,0 +1,62 @@ + $mapWithObjects + * @param array $mapWithScalars + * @param array> $mapWithAssociativeArrays + * @param array $listWithTypeHint + */ + public function __construct( + public array $mapWithObjects, + public array $mapWithScalars, + public array $mapWithAssociativeArrays, + public array $listWithoutTypeHint, + public array $listWithTypeHint, + ) { + } + + /** + * @return array + */ + public function methodMapWithObjects(): array + { + return $this->mapWithObjects; + } + + /** + * @return array + */ + public function methodMapWithScalars(): array + { + return $this->mapWithScalars; + } + + /** + * @return array> + */ + public function methodMapWithAssociativeArrays(): array + { + return $this->mapWithAssociativeArrays; + } + + public function methodListWithoutTypeHint(): array + { + return $this->listWithoutTypeHint; + } + + /** + * @return array + */ + public function methodListWithTypeHint(): array + { + return $this->listWithTypeHint; + } +} \ No newline at end of file diff --git a/src/FixturesFor81/ClassWithEnumArrayProperty.php b/src/FixturesFor81/ClassWithEnumArrayProperty.php index 09e96c5..c0a4902 100644 --- a/src/FixturesFor81/ClassWithEnumArrayProperty.php +++ b/src/FixturesFor81/ClassWithEnumArrayProperty.php @@ -1,5 +1,7 @@ objectMapper(serializeMapsAsObjects: $serializeMapsAsObjects); + + $object = $mapper->hydrateObject($class, $input); + $payload = $mapper->serializeObject($object); + + self::assertInstanceOf($class, $object); + self::assertEquals($input, $payload); + self::assertExpectedTypes($types, $object); + } + + public function arrayDataProvider(): iterable + { + yield 'associative arrays as objects when casting enabled' => [ + ClassThatSpecifiesArraysWithDocComments::class, + true, + [ + 'map_with_objects' => (object) [ + 'frank' => ['snake_case' => 'Frank'], + 'renske' => ['snake_case' => 'Renske'], + ], + 'map_with_scalars' => (object) ['one' => 1, 'two' => 2], + 'map_with_associative_arrays' => (object) [ + 'one' => ['key' => 'value'], + 'two' => ['another_key' => 'another_value'], + ], + 'list_without_type_hint' => ['Frank', 'Renske'], + 'list_with_type_hint' => ['Frank', 'Renske'], + 'method_map_with_objects' => (object) [ + 'frank' => ['snake_case' => 'Frank'], + 'renske' => ['snake_case' => 'Renske'], + ], + 'method_map_with_scalars' => (object) ['one' => 1, 'two' => 2 ], + 'method_map_with_associative_arrays' => (object) [ + 'one' => ['key' => 'value'], + 'two' => ['another_key' => 'another_value'], + ], + 'method_list_without_type_hint' => ['Frank', 'Renske'], + 'method_list_with_type_hint' => ['Frank', 'Renske'], + ], + [ + 'mapWithObjects' => ['type' => 'map', 'values' => ClassWithCamelCaseProperty::class], + 'mapWithScalars' => ['type' => 'map', 'values' => 'integer'], + 'mapWithAssociativeArrays' => ['type' => 'map', 'values' => 'array'], + 'listWithoutTypeHint' => ['type' => 'list', 'values' => 'string'], + 'listWithTypeHint' => ['type' => 'list', 'values' => 'string'], + 'methodMapWithObjects' => ['type' => 'map', 'values' => ClassWithCamelCaseProperty::class], + 'methodMapWithScalars' => ['type' => 'map', 'values' => 'integer'], + 'methodMapWithAssociativeArrays' => ['type' => 'map', 'values' => 'array'], + 'methodListWithoutTypeHint' => ['type' => 'list', 'values' => 'string'], + 'methodListWithTypeHint' => ['type' => 'list', 'values' => 'string'], + ] + ]; + + yield 'associative arrays as arrays when casting disabled' => [ + ClassThatSpecifiesArraysWithDocComments::class, + false, + [ + 'map_with_objects' => [ + 'frank' => ['snake_case' => 'Frank'], + 'renske' => ['snake_case' => 'Renske'], + ], + 'map_with_scalars' => ['one' => 1, 'two' => 2], + 'map_with_associative_arrays' => [ + 'one' => ['key' => 'value'], + 'two' => ['another_key' => 'another_value'], + ], + 'list_without_type_hint' => ['Frank', 'Renske'], + 'list_with_type_hint' => ['Frank', 'Renske'], + 'method_map_with_objects' => [ + 'frank' => ['snake_case' => 'Frank'], + 'renske' => ['snake_case' => 'Renske'], + ], + 'method_map_with_scalars' => ['one' => 1, 'two' => 2 ], + 'method_map_with_associative_arrays' => [ + 'one' => ['key' => 'value'], + 'two' => ['another_key' => 'another_value'], + ], + 'method_list_without_type_hint' => ['Frank', 'Renske'], + 'method_list_with_type_hint' => ['Frank', 'Renske'], + ], + [ + 'mapWithObjects' => ['type' => 'map', 'values' => ClassWithCamelCaseProperty::class], + 'mapWithScalars' => ['type' => 'map', 'values' => 'integer'], + 'mapWithAssociativeArrays' => ['type' => 'map', 'values' => 'array'], + 'listWithoutTypeHint' => ['type' => 'list', 'values' => 'string'], + 'listWithTypeHint' => ['type' => 'list', 'values' => 'string'], + 'methodMapWithObjects' => ['type' => 'map', 'values' => ClassWithCamelCaseProperty::class], + 'methodMapWithScalars' => ['type' => 'map', 'values' => 'integer'], + 'methodMapWithAssociativeArrays' => ['type' => 'map', 'values' => 'array'], + 'methodListWithoutTypeHint' => ['type' => 'list', 'values' => 'string'], + 'methodListWithTypeHint' => ['type' => 'list', 'values' => 'string'], + ] + ]; + + yield 'non-sequential lists serialized as objects when casting enabled' => [ + ClassThatSpecifiesArrayWithIntegerKeys::class, + true, + [ + 'array_with_integer_keys' => (object) [0 => 'zero', 2 => 'two'], + ], + [ + 'arrayWithIntegerKeys' => ['type' => 'map', 'values' => 'string'], + ], + ]; + + yield 'non-sequential lists serialized as arrays when casting disabled' => [ + ClassThatSpecifiesArrayWithIntegerKeys::class, + false, + [ + 'array_with_integer_keys' => [0 => 'zero', 2 => 'two'], + ], + [ + 'arrayWithIntegerKeys' => ['type' => 'map', 'values' => 'string'], + ], + ]; + + yield 'sequential arrays serialized as arrays when casting enabled' => [ + ClassThatSpecifiesArrayWithIntegerKeys::class, + true, + [ + 'array_with_integer_keys' => [0 => 'zero', 1 => 'one'], + ], + [ + 'arrayWithIntegerKeys' => ['type' => 'list', 'values' => 'string'], + ], + ]; + + yield 'sequential arrays serialized as arrays when casting disabled' => [ + ClassThatSpecifiesArrayWithIntegerKeys::class, + false, + [ + 'array_with_integer_keys' => [0 => 'zero', 1 => 'one'], + ], + [ + 'arrayWithIntegerKeys' => ['type' => 'list', 'values' => 'string'], + ], + ]; + } + + /** + * @test + * @dataProvider associativeArraysWithPropertySerializersDataProvider + */ + public function serializes_associative_arrays_with_property_serializers_as_objects( + bool $serializeMapsAsObjects, + ClassThatHasMultipleCastersOnMapProperty $expectedObject, + array $input, + ): void { + $mapper = $this->objectMapper($serializeMapsAsObjects); + + $object = $mapper->hydrateObject(ClassThatHasMultipleCastersOnMapProperty::class, $input); + self::assertEquals($expectedObject, $object); + + $payload = $mapper->serializeObject($object); + self::assertEquals($input, $payload); + } + + private function associativeArraysWithPropertySerializersDataProvider(): iterable + { + yield 'associative arrays as objects' => [ + true, + new ClassThatHasMultipleCastersOnMapProperty([ + 'first_level' => [ + 'second_level' => ['one' => 1, 'two' => 2, 'three' => 3], + ], + ]), + [ + 'map' => (object) ['one' => 1, 'two' => 2, 'three' => 3], + ], + ]; + + yield 'associative arrays as arrays' => [ + false, + new ClassThatHasMultipleCastersOnMapProperty([ + 'first_level' => [ + 'second_level' => ['one' => 1, 'two' => 2, 'three' => 3], + ], + ]), + [ + 'map' => ['one' => 1, 'two' => 2, 'three' => 3], + ], + ]; + } + private static function assertExpectedTypes(array $types, object $object): void { foreach ($types as $property => $type) { - $value = $object->$property; + $value = property_exists($object, $property) ? $object->{$property} : $object->$property(); self::assertExpectedType($type['type'], $value); @@ -200,8 +397,10 @@ private static function assertArrayIsMap(mixed $value): void { self::assertIsArray($value); + self::assertFalse(array_is_list($value)); + foreach (array_keys($value) as $key) { - self::assertIsString($key); + self::assertIsScalar($key); } } } diff --git a/src/IntegrationTests/HydratingSerializedObjectsUsingCodeGenerationTest.php b/src/IntegrationTests/HydratingSerializedObjectsUsingCodeGenerationTest.php index b521f0c..0db18d6 100644 --- a/src/IntegrationTests/HydratingSerializedObjectsUsingCodeGenerationTest.php +++ b/src/IntegrationTests/HydratingSerializedObjectsUsingCodeGenerationTest.php @@ -16,21 +16,24 @@ class HydratingSerializedObjectsUsingCodeGenerationTest extends HydratingSerializedObjectsTestCase { - public function objectMapper(): ObjectMapper + public function objectMapper(bool $serializeMapsAsObjects = false): ObjectMapper { - $className = 'AcmeCorp\\GeneratedHydrator'; + $shortClassName = $serializeMapsAsObjects ? 'GeneratedObjectModeHydrator' : 'GeneratedArrayModeHydrator'; + $className = 'AcmeCorp\\' . $shortClassName; if (class_exists($className)) { goto make_it; } $classes = $this->findClasses(); - $dumper = new ObjectMapperCodeGenerator(); + $dumper = new ObjectMapperCodeGenerator(serializeMapsAsObjects: $serializeMapsAsObjects); $code = $dumper->dump($classes, $className); - file_put_contents(__DIR__ . '/testHydrator.php', $code); - include __DIR__ . '/testHydrator.php'; - unlink(__DIR__ . '/testHydrator.php'); + $path = __DIR__ . '/' . $shortClassName . '.php'; + + file_put_contents($path, $code); + include_once $path; + unlink($path); make_it: diff --git a/src/IntegrationTests/HydratingSerializedObjectsUsingReflectionTest.php b/src/IntegrationTests/HydratingSerializedObjectsUsingReflectionTest.php index 13b86d1..d257fc5 100644 --- a/src/IntegrationTests/HydratingSerializedObjectsUsingReflectionTest.php +++ b/src/IntegrationTests/HydratingSerializedObjectsUsingReflectionTest.php @@ -9,8 +9,8 @@ class HydratingSerializedObjectsUsingReflectionTest extends HydratingSerializedObjectsTestCase { - public function objectMapper(): ObjectMapper + public function objectMapper(bool $serializeMapsAsObjects = false): ObjectMapper { - return new ObjectMapperUsingReflection(); + return new ObjectMapperUsingReflection(serializeMapsAsObjects: $serializeMapsAsObjects); } } diff --git a/src/NaivePropertyTypeResolver.php b/src/NaivePropertyTypeResolver.php index ed1e527..8fa7026 100644 --- a/src/NaivePropertyTypeResolver.php +++ b/src/NaivePropertyTypeResolver.php @@ -16,6 +16,7 @@ use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; +use ReflectionProperty; use RuntimeException; use function array_key_exists; use function array_shift; @@ -56,6 +57,46 @@ public function typeFromConstructorParameter( return PropertyType::fromReflectionType($type); } + public function typeFromProperty(ReflectionProperty $property, ?ReflectionMethod $constructor): PropertyType + { + $propertyType = $property->getType(); + + $resolvedType = PropertyType::fromReflectionType($propertyType); + $concreteType = $resolvedType->firstType(); + + if (!$propertyType instanceof ReflectionNamedType || !$concreteType) { + return $resolvedType; + } + + if (!$property->isPromoted()) { + $associative = $this->isAssociativeBasedOnPropertyDocComment($property); + } elseif ($constructor) { + $associative = $this->isAssociativeBasedOnConstructorDocComment($property, $constructor); + } else { + $associative = false; + } + + $concreteType->associative = $associative; + + return $resolvedType; + } + + public function typeFromMethod(ReflectionMethod $method): PropertyType + { + $returnType = $method->getReturnType(); + + $resolvedType = PropertyType::fromReflectionType($returnType); + $concreteType = $resolvedType->firstType(); + + if (!$returnType instanceof ReflectionNamedType || !$concreteType) { + return $resolvedType; + } + + $concreteType->associative = $this->isAssociativeBasedOnReturnDocComment($method); + + return $resolvedType; + } + private function resolveUseStatementMap(ReflectionClass $declaringClass): array { static $cache = []; @@ -205,4 +246,37 @@ private function extractItemType(string $type): string throw new LogicException('Unable to resolve item type for type: ' . $type); } + + private function isAssociativeBasedOnConstructorDocComment(ReflectionProperty $parameter, ReflectionMethod $constructor): bool + { + $docBlock = $constructor->getDocComment(); + if (!$docBlock) { + return false; + } + + return (bool) preg_match( + '/\*\s+@param\s+[^$]*array<\s*string\s*,\s*[^>]+\s*>[^$]*\$' . preg_quote($parameter->name, '/') . '\b/m', + $docBlock + ); + } + + private function isAssociativeBasedOnPropertyDocComment(ReflectionProperty $property): bool + { + $docBlock = $property->getDocComment(); + if (!$docBlock) { + return false; + } + + return (bool) preg_match('/\*\s+@var\s+[^*]*?\barray\s*<\s*string\s*,\s*[^>]+\s*>/m', $docBlock); + } + + private function isAssociativeBasedOnReturnDocComment(ReflectionMethod $method): bool + { + $docBlock = $method->getDocComment(); + if (!$docBlock) { + return false; + } + + return (bool) preg_match('/\*\s+@return\s+[^*]*?\barray\s*<\s*string\s*,\s*[^>]+\s*>/m', $docBlock); + } } diff --git a/src/ObjectMapperCodeGenerator.php b/src/ObjectMapperCodeGenerator.php index 36796fd..4092870 100644 --- a/src/ObjectMapperCodeGenerator.php +++ b/src/ObjectMapperCodeGenerator.php @@ -19,13 +19,16 @@ final class ObjectMapperCodeGenerator { private DefinitionProvider $definitionProvider; private bool $omitNullValuesOnSerialization; + private bool $serializeMapsAsObjects; public function __construct( ?DefinitionProvider $definitionProvider = null, bool $omitNullValuesOnSerialization = false, + bool $serializeMapsAsObjects = false, ) { $this->definitionProvider = $definitionProvider ?? new DefinitionProvider(); $this->omitNullValuesOnSerialization = $omitNullValuesOnSerialization; + $this->serializeMapsAsObjects = $serializeMapsAsObjects; } public function dump(array $classes, string $dumpedClassName): string @@ -255,6 +258,14 @@ private function dumpClassHydrator(string $className, ClassHydrationDefinition $ } CODE; + if ($definition->propertyType->isCollection() || $definition->firstTypeName === 'array') { + $body .= <<accessorName; $keys = $definition->keys; + $condition = $this->omitNullValuesOnSerialization ? "if ($tempVariable !== null) " : ''; if (count($keys) === 1) { $key = '[\'' . implode('\'][\'', array_pop($keys)) . '\']'; + $associativeArray = $definition->propertyType->isAssociativeArray(); + + if ($this->serializeMapsAsObjects && !$associativeArray && $definition->propertyType->firstTypeName() === 'array') { + return " $condition\$result$key = is_array($tempVariable) && !array_is_list($tempVariable) ? (object) $tempVariable : $tempVariable;\n"; + } + + $cast = $this->serializeMapsAsObjects && $associativeArray ? '(object) ' : ''; - return $this->omitNullValuesOnSerialization - ? " if ($tempVariable !== null) \$result$key = $tempVariable;\n" - : " \$result$key = $tempVariable;\n"; + return " $condition\$result$key = {$cast}$tempVariable;\n"; } $code = ''; foreach ($keys as $tempKey => $resultKey) { $key = '[\'' . implode('\'][\'', $resultKey) . '\']'; - $code .= $this->omitNullValuesOnSerialization - ? " if ({$tempVariable}['$tempKey'] !== null) \$result$key = {$tempVariable}['$tempKey'];" - : " \$result$key = {$tempVariable}['$tempKey'];"; + $code .= " $condition\$result$key = {$tempVariable}['$tempKey'];"; } return $code; diff --git a/src/ObjectMapperUsingReflection.php b/src/ObjectMapperUsingReflection.php index b0c398c..235f5d3 100644 --- a/src/ObjectMapperUsingReflection.php +++ b/src/ObjectMapperUsingReflection.php @@ -38,13 +38,16 @@ class ObjectMapperUsingReflection implements ObjectMapper private array $hydrationStack = []; private bool $omitNullValuesOnSerialization; + private bool $serializeMapsAsObjects; public function __construct( ?DefinitionProvider $definitionProvider = null, bool $omitNullValuesOnSerialization = false, + bool $serializeMapsAsObjects = false, ) { $this->definitionProvider = $definitionProvider ?? new DefinitionProvider(); $this->omitNullValuesOnSerialization = $omitNullValuesOnSerialization; + $this->serializeMapsAsObjects = $serializeMapsAsObjects; } private function extractPayloadViaMap(array $payload, array $inputMap): mixed @@ -137,6 +140,10 @@ public function hydrateObject(string $className, array $payload): object $value = $this->hydrateViaTypeMap($definition, $value); } + if (is_object($value) && ($definition->propertyType->isCollection() || $definition->firstTypeName === 'array')) { + $value = (array) $value; + } + $typeName = $definition->firstTypeName; if ($definition->isBackedEnum()) { @@ -298,6 +305,8 @@ public function serializeObjectOfType(object $object, string $className): mixed $value = $value->name; } elseif (is_object($value)) { $value = $defaults + $this->serializeObject($value); + } elseif ($this->serializeMapsAsObjects && ($property->propertyType->isAssociativeArray() || (is_array($value) && !array_is_list($value)))) { + $value = (object) $value; } $this->assignToResult($keys, $result, $value); diff --git a/src/PropertyType.php b/src/PropertyType.php index 1ae2ee5..489976c 100644 --- a/src/PropertyType.php +++ b/src/PropertyType.php @@ -8,6 +8,7 @@ use ReflectionClass; use ReflectionIntersectionType; use ReflectionNamedType; +use ReflectionProperty; use ReflectionUnionType; use function count; use function enum_exists; @@ -78,6 +79,13 @@ public function canBeHydrated(): bool && ($this->concreteTypes[0]->isBuiltIn === false); } + public function isAssociativeArray(): bool + { + return count($this->concreteTypes) === 1 + && $this->concreteTypes[0]->name === 'array' + && $this->concreteTypes[0]->associative; + } + public static function fromReflectionType( ReflectionUnionType|ReflectionIntersectionType|ReflectionNamedType|null $type ): PropertyType { @@ -132,6 +140,11 @@ public static function mixed(): static return new static(true, new ConcreteType('mixed', true)); } + public function firstType(): ?ConcreteType + { + return $this->concreteTypes[0] ?? null; + } + public function firstTypeName(): ?string { return $this->concreteTypes[0]?->name; diff --git a/src/PropertyTypeResolver.php b/src/PropertyTypeResolver.php index 9f2984e..cdbba4b 100644 --- a/src/PropertyTypeResolver.php +++ b/src/PropertyTypeResolver.php @@ -6,6 +6,7 @@ use ReflectionMethod; use ReflectionParameter; +use ReflectionProperty; interface PropertyTypeResolver { @@ -13,4 +14,11 @@ public function typeFromConstructorParameter( ReflectionParameter $parameter, ReflectionMethod $constructor ): PropertyType; + + public function typeFromProperty( + ReflectionProperty $property, + ?ReflectionMethod $constructor + ): PropertyType; + + public function typeFromMethod(ReflectionMethod $method): PropertyType; }