From d40e2968e9827ca3cedb2e6a8e9a0864c2eb87aa Mon Sep 17 00:00:00 2001 From: Priyadi Iman Nurcahyo <1102197+priyadi@users.noreply.github.com> Date: Tue, 20 Feb 2024 12:31:54 +0700 Subject: [PATCH] feat: Supports dynamic properties (including `stdClass`) on the target side. --- CHANGELOG.md | 1 + phpunit.xml.dist | 3 +- .../ObjectToObjectMetadataFactory.php | 36 ++++++++-- .../ObjectToObjectMetadata.php | 64 ++++++++++------- .../ObjectToObjectMetadata/ReadMode.php | 1 + src/Transformer/Util/ReaderWriter.php | 6 ++ src/Util/ClassUtil.php | 4 ++ templates/data_collector.html.twig | 10 ++- .../ObjectExtendingStdClass.php | 18 +++++ tests/IntegrationTest/DynamicPropertyTest.php | 71 +++++++++++++++++++ 10 files changed, 183 insertions(+), 31 deletions(-) create mode 100644 tests/Fixtures/DynamicProperty/ObjectExtendingStdClass.php create mode 100644 tests/IntegrationTest/DynamicPropertyTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f660a17..8e26d88a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * feat: `PresetTransformer`. * fix: Typo in `RemoveOptionalDefinitionPass` +* feat: Supports dynamic properties (including `stdClass`) on the target side. ## 1.0.0 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f60adcf4..7b4a0a40 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,8 @@ failOnRisky="true" failOnWarning="false" cacheDirectory=".phpunit.cache" - beStrictAboutCoverageMetadata="true"> + beStrictAboutCoverageMetadata="true" + displayDetailsOnTestsThatTriggerWarnings="true"> tests diff --git a/src/Transformer/ObjectToObjectMetadata/Implementation/ObjectToObjectMetadataFactory.php b/src/Transformer/ObjectToObjectMetadata/Implementation/ObjectToObjectMetadataFactory.php index 332af00f..6aeded28 100644 --- a/src/Transformer/ObjectToObjectMetadata/Implementation/ObjectToObjectMetadataFactory.php +++ b/src/Transformer/ObjectToObjectMetadata/Implementation/ObjectToObjectMetadataFactory.php @@ -81,9 +81,12 @@ public function createObjectToObjectMetadata( $targetReflection = new \ReflectionClass($targetClass); - // check if source and target classes are internal + $sourceAllowsDynamicProperties = $this->allowsDynamicProperties($sourceReflection); + $targetAllowsDynamicProperties = $this->allowsDynamicProperties($targetReflection); - if ($sourceReflection->isInternal()) { + // check if source and target classes are internal. we allow stdClass at + // the source side + if (!$sourceAllowsDynamicProperties && $sourceReflection->isInternal()) { throw new InternalClassUnsupportedException($sourceClass); } @@ -143,9 +146,16 @@ public function createObjectToObjectMetadata( // process source read mode if ($sourceReadInfo === null) { - $sourceReadMode = ReadMode::None; - $sourceReadName = null; - $sourceReadVisibility = Visibility::None; + // if source allows dynamic properties, including stdClass + if ($sourceAllowsDynamicProperties) { + $sourceReadMode = ReadMode::DynamicProperty; + $sourceReadName = $sourceProperty; + $sourceReadVisibility = Visibility::Public; + } else { + $sourceReadMode = ReadMode::None; + $sourceReadName = null; + $sourceReadVisibility = Visibility::None; + } } else { $sourceReadMode = match ($sourceReadInfo->getType()) { PropertyReadInfo::TYPE_METHOD => ReadMode::Method, @@ -320,6 +330,8 @@ public function createObjectToObjectMetadata( sourceClass: $sourceClass, targetClass: $targetClass, providedTargetClass: $providedTargetClass, + sourceAllowsDynamicProperties: $sourceAllowsDynamicProperties, + targetAllowsDynamicProperties: $targetAllowsDynamicProperties, allPropertyMappings: $propertyMappings, instantiable: $instantiable, cloneable: $cloneable, @@ -412,4 +424,18 @@ private function listInitializableProperties( return $initializableProperties; } + + /** + * @param \ReflectionClass $class + */ + private function allowsDynamicProperties(\ReflectionClass $class): bool + { + do { + if (count($class->getAttributes(\AllowDynamicProperties::class)) > 0) { + return true; + } + } while ($class = $class->getParentClass()); + + return false; + } } diff --git a/src/Transformer/ObjectToObjectMetadata/ObjectToObjectMetadata.php b/src/Transformer/ObjectToObjectMetadata/ObjectToObjectMetadata.php index 36dbd9f4..0e348871 100644 --- a/src/Transformer/ObjectToObjectMetadata/ObjectToObjectMetadata.php +++ b/src/Transformer/ObjectToObjectMetadata/ObjectToObjectMetadata.php @@ -58,6 +58,8 @@ public function __construct( private string $sourceClass, private string $targetClass, private string $providedTargetClass, + private bool $sourceAllowsDynamicProperties, + private bool $targetAllowsDynamicProperties, array $allPropertyMappings, private bool $instantiable, private bool $cloneable, @@ -104,18 +106,20 @@ public function withTargetProxy( bool $constructorIsEager, ): self { return new self( - $this->sourceClass, - $this->targetClass, - $this->providedTargetClass, - $this->allPropertyMappings, - $this->instantiable, - $this->cloneable, - $this->initializableTargetPropertiesNotInSource, - $this->sourceModifiedTime, - $this->targetModifiedTime, - $this->targetReadOnly, - $constructorIsEager, - $targetProxySkippedProperties, + sourceClass: $this->sourceClass, + targetClass: $this->targetClass, + providedTargetClass: $this->providedTargetClass, + sourceAllowsDynamicProperties: $this->sourceAllowsDynamicProperties, + targetAllowsDynamicProperties: $this->targetAllowsDynamicProperties, + allPropertyMappings: $this->allPropertyMappings, + instantiable: $this->instantiable, + cloneable: $this->cloneable, + initializableTargetPropertiesNotInSource: $this->initializableTargetPropertiesNotInSource, + sourceModifiedTime: $this->sourceModifiedTime, + targetModifiedTime: $this->targetModifiedTime, + targetReadOnly: $this->targetReadOnly, + constructorIsEager: $constructorIsEager, + targetProxySkippedProperties: $targetProxySkippedProperties, cannotUseProxyReason: null ); } @@ -124,18 +128,20 @@ public function withReasonCannotUseProxy( string $reason ): self { return new self( - $this->sourceClass, - $this->targetClass, - $this->providedTargetClass, - $this->allPropertyMappings, - $this->instantiable, - $this->cloneable, - $this->initializableTargetPropertiesNotInSource, - $this->sourceModifiedTime, - $this->targetModifiedTime, - $this->targetReadOnly, - $this->constructorIsEager, - [], + sourceClass: $this->sourceClass, + targetClass: $this->targetClass, + providedTargetClass: $this->providedTargetClass, + sourceAllowsDynamicProperties: $this->sourceAllowsDynamicProperties, + targetAllowsDynamicProperties: $this->targetAllowsDynamicProperties, + allPropertyMappings: $this->allPropertyMappings, + instantiable: $this->instantiable, + cloneable: $this->cloneable, + initializableTargetPropertiesNotInSource: $this->initializableTargetPropertiesNotInSource, + sourceModifiedTime: $this->sourceModifiedTime, + targetModifiedTime: $this->targetModifiedTime, + targetReadOnly: $this->targetReadOnly, + constructorIsEager: $this->constructorIsEager, + targetProxySkippedProperties: [], cannotUseProxyReason: $reason, ); } @@ -277,4 +283,14 @@ public function constructorIsEager(): bool { return $this->constructorIsEager; } + + public function getSourceAllowsDynamicProperties(): bool + { + return $this->sourceAllowsDynamicProperties; + } + + public function getTargetAllowsDynamicProperties(): bool + { + return $this->targetAllowsDynamicProperties; + } } diff --git a/src/Transformer/ObjectToObjectMetadata/ReadMode.php b/src/Transformer/ObjectToObjectMetadata/ReadMode.php index 5b7c4ddd..9f4a5124 100644 --- a/src/Transformer/ObjectToObjectMetadata/ReadMode.php +++ b/src/Transformer/ObjectToObjectMetadata/ReadMode.php @@ -21,4 +21,5 @@ enum ReadMode case None; case Method; case Property; + case DynamicProperty; } diff --git a/src/Transformer/Util/ReaderWriter.php b/src/Transformer/Util/ReaderWriter.php index 9ab654e5..3af54528 100644 --- a/src/Transformer/Util/ReaderWriter.php +++ b/src/Transformer/Util/ReaderWriter.php @@ -60,6 +60,12 @@ public function readSourceProperty( } elseif ($mode === ReadMode::Method) { /** @psalm-suppress MixedMethodCall */ return $source->{$accessorName}(); + } elseif ($mode === ReadMode::DynamicProperty) { + if (isset($source->{$accessorName})) { + return $source->{$accessorName}; + } else { + return null; + } } return null; } catch (\Error $e) { diff --git a/src/Util/ClassUtil.php b/src/Util/ClassUtil.php index 5d4025e7..77136477 100644 --- a/src/Util/ClassUtil.php +++ b/src/Util/ClassUtil.php @@ -80,6 +80,10 @@ public static function getLastModifiedTime( $class = new \ReflectionClass($class); } + if ($class->isInternal()) { + return 0; + } + $fileName = $class->getFileName(); if ($fileName === false) { diff --git a/templates/data_collector.html.twig b/templates/data_collector.html.twig index f03ad7ef..c82ca24f 100644 --- a/templates/data_collector.html.twig +++ b/templates/data_collector.html.twig @@ -251,7 +251,12 @@ Source class - {{ metadata.sourceClass|abbr_class }} + + {{ metadata.sourceClass|abbr_class }} + {% if metadata.sourceAllowsDynamicProperties %} + Allows dynamic properties + {% endif %} + Wanted target class @@ -267,6 +272,9 @@ {% if not metadata.instantiable %} Not instantiable {% endif %} + {% if metadata.targetAllowsDynamicProperties %} + Allows dynamic properties + {% endif %} diff --git a/tests/Fixtures/DynamicProperty/ObjectExtendingStdClass.php b/tests/Fixtures/DynamicProperty/ObjectExtendingStdClass.php new file mode 100644 index 00000000..45f3b52a --- /dev/null +++ b/tests/Fixtures/DynamicProperty/ObjectExtendingStdClass.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\DynamicProperty; + +class ObjectExtendingStdClass extends \stdClass +{ +} diff --git a/tests/IntegrationTest/DynamicPropertyTest.php b/tests/IntegrationTest/DynamicPropertyTest.php new file mode 100644 index 00000000..547cc5d4 --- /dev/null +++ b/tests/IntegrationTest/DynamicPropertyTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\IntegrationTest; + +use Rekalogika\Mapper\Tests\Common\FrameworkTestCase; +use Rekalogika\Mapper\Tests\Fixtures\DynamicProperty\ObjectExtendingStdClass; +use Rekalogika\Mapper\Tests\Fixtures\ScalarDto\ObjectWithScalarPropertiesDto; + +class DynamicPropertyTest extends FrameworkTestCase +{ + public function testStdClassToObject(): void + { + $source = new \stdClass(); + $source->a = 1; + $source->b = 'string'; + $source->c = true; + $source->d = 1.1; + + $target = $this->mapper->map($source, ObjectWithScalarPropertiesDto::class); + + $this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $target); + $this->assertSame(1, $target->a); + $this->assertSame('string', $target->b); + $this->assertTrue($target->c); + $this->assertSame(1.1, $target->d); + } + + public function testObjectExtendingStdClassToObject(): void + { + $source = new ObjectExtendingStdClass(); + /** @psalm-suppress UndefinedPropertyAssignment */ + $source->a = 1; + /** @psalm-suppress UndefinedPropertyAssignment */ + $source->b = 'string'; + /** @psalm-suppress UndefinedPropertyAssignment */ + $source->c = true; + /** @psalm-suppress UndefinedPropertyAssignment */ + $source->d = 1.1; + + $target = $this->mapper->map($source, ObjectWithScalarPropertiesDto::class); + + $this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $target); + $this->assertSame(1, $target->a); + $this->assertSame('string', $target->b); + $this->assertTrue($target->c); + $this->assertSame(1.1, $target->d); + } + + public function testStdClassWithoutPropertiesToObject(): void + { + $source = new \stdClass(); + $target = $this->mapper->map($source, ObjectWithScalarPropertiesDto::class); + + $this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $target); + $this->assertNull($target->a); + $this->assertNull($target->b); + $this->assertNull($target->c); + $this->assertNull($target->d); + } +}