From 654791659a4f503a2ff85b0bf4c2690d247cf9ac Mon Sep 17 00:00:00 2001 From: Priyadi Iman Nurcahyo <1102197+priyadi@users.noreply.github.com> Date: Wed, 17 Jan 2024 23:56:43 +0700 Subject: [PATCH] perf: Use our `PropertyAccessLite` instead of Symfony's. --- CHANGELOG.md | 1 + config/services.php | 8 +- psalm.xml | 5 + src/MapperFactory/MapperFactory.php | 27 +++- src/PropertyAccessLite/PropertyAccessLite.php | 146 ++++++++++++++++++ .../ObjectWithVariousAccessMethods.php | 59 +++++++ .../Transformer/PropertyAccessLiteTest.php | 101 ++++++++++++ 7 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 src/PropertyAccessLite/PropertyAccessLite.php create mode 100644 tests/Fixtures/AccessMethods/ObjectWithVariousAccessMethods.php create mode 100644 tests/UnitTest/Transformer/PropertyAccessLiteTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b029c14..e935a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * perf: Optimize `guessTypeFromVariable` * perf: Optimize `Context` & `ObjectCache` * perf: Change 'simpletypes' cache to array. +* perf: Use our `PropertyAccessLite` instead of Symfony's. ## 0.5.14 diff --git a/config/services.php b/config/services.php index 61a2632..8a93a1f 100644 --- a/config/services.php +++ b/config/services.php @@ -23,6 +23,7 @@ use Rekalogika\Mapper\MethodMapper\ClassMethodTransformer; use Rekalogika\Mapper\MethodMapper\SubMapper; use Rekalogika\Mapper\ObjectCache\ObjectCacheFactory; +use Rekalogika\Mapper\PropertyAccessLite\PropertyAccessLite; use Rekalogika\Mapper\Transformer\ArrayToObjectTransformer; use Rekalogika\Mapper\Transformer\CopyTransformer; use Rekalogika\Mapper\Transformer\DateTimeTransformer; @@ -138,7 +139,7 @@ $services ->set(ObjectToObjectTransformer::class) ->args([ - '$propertyAccessor' => service('property_accessor'), + '$propertyAccessor' => service('rekalogika.mapper.property_access_lite'), '$typeResolver' => service('rekalogika.mapper.type_resolver'), '$objectMappingResolver' => service('rekalogika.mapper.object_mapping_resolver'), ]) @@ -174,6 +175,11 @@ ]) ->tag('kernel.cache_warmer'); + # propertyaccess lite + + $services + ->set('rekalogika.mapper.property_access_lite', PropertyAccessLite::class); + # type resolver $services diff --git a/psalm.xml b/psalm.xml index c297242..1a7513e 100644 --- a/psalm.xml +++ b/psalm.xml @@ -46,5 +46,10 @@ + + + + + diff --git a/src/MapperFactory/MapperFactory.php b/src/MapperFactory/MapperFactory.php index 0d9ac00..3cd09c1 100644 --- a/src/MapperFactory/MapperFactory.php +++ b/src/MapperFactory/MapperFactory.php @@ -27,6 +27,7 @@ use Rekalogika\Mapper\MethodMapper\SubMapper; use Rekalogika\Mapper\ObjectCache\ObjectCacheFactory; use Rekalogika\Mapper\ObjectCache\ObjectCacheFactoryInterface; +use Rekalogika\Mapper\PropertyAccessLite\PropertyAccessLite; use Rekalogika\Mapper\Transformer\ArrayToObjectTransformer; use Rekalogika\Mapper\Transformer\Contracts\TransformerInterface; use Rekalogika\Mapper\Transformer\CopyTransformer; @@ -49,8 +50,6 @@ use Rekalogika\Mapper\TypeResolver\TypeResolverInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Console\Application; -use Symfony\Component\PropertyAccess\PropertyAccess; -use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -58,7 +57,9 @@ use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; @@ -114,7 +115,7 @@ public function __construct( private array $additionalTransformers = [], private ?ReflectionExtractor $reflectionExtractor = null, private ?PhpStanExtractor $phpStanExtractor = null, - private ?PropertyAccessor $propertyAccessor = null, + private ?PropertyAccessorInterface $propertyAccessor = null, private ?NormalizerInterface $normalizer = null, private ?DenormalizerInterface $denormalizer = null, ?CacheItemPoolInterface $propertyInfoExtractorCache = null, @@ -153,6 +154,16 @@ private function getPhpStanExtractor(): PropertyTypeExtractorInterface return $this->phpStanExtractor; } + private function getPropertyReadInfoExtractor(): PropertyReadInfoExtractorInterface + { + return $this->getReflectionExtractor(); + } + + private function getPropertyWriteInfoExtractor(): PropertyWriteInfoExtractorInterface + { + return $this->getReflectionExtractor(); + } + private function getPropertyInfoExtractor(): PropertyInfoExtractorInterface&PropertyInitializableExtractorInterface { if ($this->propertyInfoExtractor === null) { @@ -188,7 +199,7 @@ private function getPropertyInfoExtractor(): PropertyInfoExtractorInterface&Prop private function getConcretePropertyAccessor(): PropertyAccessorInterface { if (null === $this->propertyAccessor) { - $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); + $this->propertyAccessor = new PropertyAccessLite(); } return $this->propertyAccessor; @@ -258,9 +269,9 @@ protected function getObjectToObjectTransformer(): TransformerInterface { if (null === $this->objectToObjectTransformer) { $this->objectToObjectTransformer = new ObjectToObjectTransformer( - $this->getPropertyAccessor(), - $this->getTypeResolver(), - $this->getObjectMappingResolver(), + propertyAccessor: $this->getPropertyAccessor(), + typeResolver: $this->getTypeResolver(), + objectMappingResolver: $this->getObjectMappingResolver(), ); } @@ -395,6 +406,8 @@ protected function getObjectMappingResolver(): ObjectMappingResolverInterface $this->getPropertyInfoExtractor(), $this->getPropertyInfoExtractor(), $this->getPropertyInfoExtractor(), + $this->getPropertyReadInfoExtractor(), + $this->getPropertyWriteInfoExtractor(), ); } diff --git a/src/PropertyAccessLite/PropertyAccessLite.php b/src/PropertyAccessLite/PropertyAccessLite.php new file mode 100644 index 0000000..24b518f --- /dev/null +++ b/src/PropertyAccessLite/PropertyAccessLite.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\PropertyAccessLite; + +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; +use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +final class PropertyAccessLite implements PropertyAccessorInterface +{ + /** + * @param object|array $objectOrArray + */ + public function getValue( + object|array $objectOrArray, + string|PropertyPathInterface $propertyPath, + ): mixed { + assert(is_string($propertyPath)); + assert(\is_object($objectOrArray)); + + $getter = 'get' . ucfirst($propertyPath); + + try { + /** @psalm-suppress MixedMethodCall */ + return $objectOrArray->{$getter}(); + } catch (\Throwable $e) { + if (!\str_starts_with($e->getMessage(), 'Call to undefined method')) { + if (\str_starts_with($e->getMessage(), 'Typed property')) { + throw new UninitializedPropertyException(\sprintf( + 'Property "%s" is not initialized in object "%s"', + $propertyPath, + \get_class($objectOrArray), + ), 0, $e); + } + + throw $e; + } + } + + try { + return $objectOrArray->{$propertyPath}; + // @phpstan-ignore-next-line + } catch (\Throwable $e) { + if (\str_starts_with($e->getMessage(), 'Typed property')) { + throw new UninitializedPropertyException(\sprintf( + 'Property "%s" is not initialized in object "%s"', + $propertyPath, + \get_class($objectOrArray), + ), 0, $e); + } elseif (\str_starts_with($e->getMessage(), 'Cannot access private property')) { + throw new NoSuchPropertyException(\sprintf( + 'Property "%s" is not public in object "%s"', + $propertyPath, + \get_class($objectOrArray), + ), 0, $e); + } elseif (\str_starts_with($e->getMessage(), 'Undefined property')) { + throw new NoSuchPropertyException(\sprintf( + 'Property "%s" is not defined in object "%s"', + $propertyPath, + \get_class($objectOrArray), + ), 0, $e); + } + + throw $e; + } + } + + /** + * @param object|array $objectOrArray + */ + public function setValue( + object|array &$objectOrArray, + string|PropertyPathInterface $propertyPath, + mixed $value + ): void { + assert(is_string($propertyPath)); + assert(\is_object($objectOrArray)); + + $setter = 'set' . ucfirst($propertyPath); + + try { + /** @psalm-suppress MixedMethodCall */ + $objectOrArray->{$setter}($value); + return; + } catch (\Throwable $e) { + if (!\str_starts_with($e->getMessage(), 'Call to undefined method')) { + throw $e; + } + } + + try { + if (!\property_exists($objectOrArray, $propertyPath)) { + throw new NoSuchPropertyException(\sprintf( + 'Property "%s" is not defined in object "%s"', + $propertyPath, + \get_class($objectOrArray), + )); + } + $objectOrArray->{$propertyPath} = $value; + } catch (NoSuchPropertyException $e) { + throw $e; + } catch (\Throwable $e) { + if (\str_starts_with($e->getMessage(), 'Cannot access private property')) { + throw new NoSuchPropertyException(\sprintf( + 'Property "%s" is not public in object "%s"', + $propertyPath, + \get_class($objectOrArray), + ), 0, $e); + } + + throw $e; + } + } + + /** + * @param object|array $objectOrArray + */ + public function isWritable( + object|array $objectOrArray, + string|PropertyPathInterface $propertyPath + ): bool { + throw new \RuntimeException('Not implemented'); + } + + /** + * @param object|array $objectOrArray + */ + public function isReadable( + object|array $objectOrArray, + string|PropertyPathInterface $propertyPath + ): bool { + throw new \RuntimeException('Not implemented'); + } +} diff --git a/tests/Fixtures/AccessMethods/ObjectWithVariousAccessMethods.php b/tests/Fixtures/AccessMethods/ObjectWithVariousAccessMethods.php new file mode 100644 index 0000000..d7de27c --- /dev/null +++ b/tests/Fixtures/AccessMethods/ObjectWithVariousAccessMethods.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\AccessMethods; + +class ObjectWithVariousAccessMethods +{ + private string $privatePropertyWithGetterSetter = 'privateProperty'; + // @phpstan-ignore-next-line + private string $privatePropertyWithoutGetterSetter = 'privatePropertyWithoutGetterSetter'; + public string $publicPropertyWithGetterSetter = 'publicProperty'; + public string $publicPropertyWithoutGetterSetter = 'publicPropertyWithoutGetterSetter'; + + + public bool $publicPropertySetterAccessed = false; + public bool $publicPropertyGetterAccessed = false; + + public string $unsetPublicProperty; + + // @phpstan-ignore-next-line + private string $unsetPrivatePropertyWithGetter; + + public function getPrivateProperty(): string + { + return $this->privatePropertyWithGetterSetter; + } + + public function setPrivateProperty(string $privateProperty): void + { + $this->privatePropertyWithGetterSetter = $privateProperty; + } + + public function getPublicProperty(): string + { + $this->publicPropertyGetterAccessed = true; + return $this->publicPropertyWithGetterSetter; + } + + public function setPublicProperty(string $publicProperty): void + { + $this->publicPropertySetterAccessed = true; + $this->publicPropertyWithGetterSetter = $publicProperty; + } + + public function getUnsetPrivatePropertyWithGetter(): string + { + return $this->unsetPrivatePropertyWithGetter; + } +} diff --git a/tests/UnitTest/Transformer/PropertyAccessLiteTest.php b/tests/UnitTest/Transformer/PropertyAccessLiteTest.php new file mode 100644 index 0000000..92d2046 --- /dev/null +++ b/tests/UnitTest/Transformer/PropertyAccessLiteTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\UnitTest\Transformer; + +use PHPUnit\Framework\TestCase; +use Rekalogika\Mapper\PropertyAccessLite\PropertyAccessLite; +use Rekalogika\Mapper\Tests\Fixtures\AccessMethods\ObjectWithVariousAccessMethods; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; +use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; + +class PropertyAccessLiteTest extends TestCase +{ + public function testGetterSetter(): void + { + $accessor = new PropertyAccessLite(); + $object = new ObjectWithVariousAccessMethods(); + $object2 = $object; + + $accessor->setValue($object, 'publicProperty', 'foo'); + $accessor->setValue($object, 'privateProperty', 'foo'); + + $this->assertSame('foo', $accessor->getValue($object, 'publicProperty')); + $this->assertSame('foo', $accessor->getValue($object, 'privateProperty')); + + $this->assertTrue($object2->publicPropertySetterAccessed); + $this->assertTrue($object2->publicPropertyGetterAccessed); + } + + public function testGetPrivateProperty(): void + { + $accessor = new PropertyAccessLite(); + $object = new ObjectWithVariousAccessMethods(); + + $this->expectException(NoSuchPropertyException::class); + + $accessor->getValue($object, 'privatePropertyWithoutGetterSetter'); + } + + public function testSetPrivateProperty(): void + { + $accessor = new PropertyAccessLite(); + $object = new ObjectWithVariousAccessMethods(); + + $this->expectException(NoSuchPropertyException::class); + + $accessor->setValue($object, 'privatePropertyWithoutGetterSetter', 'foo'); + } + + public function testPublic(): void + { + $accessor = new PropertyAccessLite(); + $object = new ObjectWithVariousAccessMethods(); + + $accessor->setValue($object, 'publicPropertyWithoutGetterSetter', 'foo'); + + $this->assertSame('foo', $accessor->getValue($object, 'publicPropertyWithoutGetterSetter')); + } + + public function testGetUnsetPublicProperty(): void + { + $accessor = new PropertyAccessLite(); + $object = new ObjectWithVariousAccessMethods(); + $this->expectException(UninitializedPropertyException::class); + $accessor->getValue($object, 'unsetPublicProperty'); + } + + public function testGetUnsetGetter(): void + { + $accessor = new PropertyAccessLite(); + $object = new ObjectWithVariousAccessMethods(); + $this->expectException(UninitializedPropertyException::class); + $accessor->getValue($object, 'unsetPrivatePropertyWithGetter'); + } + + public function testGetMissingProperty(): void + { + $accessor = new PropertyAccessLite(); + $object = new ObjectWithVariousAccessMethods(); + $this->expectException(NoSuchPropertyException::class); + $accessor->getValue($object, 'missingProperty'); + } + + public function testSetMissingProperty(): void + { + $accessor = new PropertyAccessLite(); + $object = new ObjectWithVariousAccessMethods(); + $this->expectException(NoSuchPropertyException::class); + $accessor->setValue($object, 'missingProperty', 'foo'); + } +}