From cee44e0a82357d78ba0834dd80e6250f1097d0be Mon Sep 17 00:00:00 2001
From: Priyadi Iman Nurcahyo <1102197+priyadi@users.noreply.github.com>
Date: Wed, 17 Jan 2024 23:54:37 +0700
Subject: [PATCH] perf: Use our `PropertyAccessLite` instead of Symfony's.
---
CHANGELOG.md | 1 +
config/services.php | 10 +-
psalm.xml | 5 +
src/MapperFactory/MapperFactory.php | 27 +++-
src/PropertyAccessLite/PropertyAccessLite.php | 146 ++++++++++++++++++
.../ObjectMappingResolver.php | 55 ++++++-
.../ObjectWithVariousAccessMethods.php | 59 +++++++
.../Transformer/PropertyAccessLiteTest.php | 101 ++++++++++++
8 files changed, 393 insertions(+), 11 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..12ac0d1 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
@@ -195,6 +201,8 @@
service('rekalogika.mapper.property_info'),
service('rekalogika.mapper.property_info'),
service('rekalogika.mapper.property_info'),
+ service('property_info.reflection_extractor'),
+ service('property_info.reflection_extractor'),
]);
$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/src/Transformer/ObjectMappingResolver/ObjectMappingResolver.php b/src/Transformer/ObjectMappingResolver/ObjectMappingResolver.php
index 4a0a530..cef5ef4 100644
--- a/src/Transformer/ObjectMappingResolver/ObjectMappingResolver.php
+++ b/src/Transformer/ObjectMappingResolver/ObjectMappingResolver.php
@@ -22,7 +22,9 @@
use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyListExtractorInterface;
+use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
+use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface;
final class ObjectMappingResolver implements ObjectMappingResolverInterface
{
@@ -31,6 +33,8 @@ public function __construct(
private PropertyListExtractorInterface $propertyListExtractor,
private PropertyInitializableExtractorInterface $propertyInitializableExtractor,
private PropertyTypeExtractorInterface $propertyTypeExtractor,
+ private PropertyReadInfoExtractorInterface $propertyReadInfoExtractor,
+ private PropertyWriteInfoExtractorInterface $propertyWriteInfoExtractor,
) {
}
@@ -75,10 +79,55 @@ public function resolveObjectMapping(
);
}
+ $sourcePropertyReadInfo = $this->propertyReadInfoExtractor
+ ->getReadInfo($sourceClass, $sourceProperty);
+
+ if (null === $sourcePropertyReadInfo) {
+ throw new InvalidArgumentException(
+ sprintf(
+ 'Cannot get read info of source property "%s::$%s".',
+ $sourceClass,
+ $sourceProperty
+ ),
+ context: $context
+ );
+ }
+
+ $targetPropertyReadInfo = $this->propertyReadInfoExtractor
+ ->getReadInfo($targetClass, $targetProperty);
+
+ if (null === $targetPropertyReadInfo) {
+ throw new InvalidArgumentException(
+ sprintf(
+ 'Cannot get read info of target property "%s::$%s".',
+ $targetClass,
+ $targetProperty
+ ),
+ context: $context
+ );
+ }
+
+ $targetPropertyWriteInfo = $this->propertyWriteInfoExtractor
+ ->getWriteInfo($targetClass, $targetProperty);
+
+ if (null === $targetPropertyWriteInfo) {
+ throw new InvalidArgumentException(
+ sprintf(
+ 'Cannot get write info of target property "%s::$%s".',
+ $targetClass,
+ $targetProperty
+ ),
+ context: $context
+ );
+ }
+
$propertyResults[] = new PropertyMapping(
- $sourceProperty,
- $targetProperty,
- $targetPropertyTypes,
+ sourceProperty: $sourceProperty,
+ targetProperty: $targetProperty,
+ targetTypes: $targetPropertyTypes,
+ sourcePropertyReadInfo: $sourcePropertyReadInfo,
+ targetPropertyReadInfo: $targetPropertyReadInfo,
+ targetPropertyWriteInfo: $targetPropertyWriteInfo,
);
}
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');
+ }
+}