From e6a056008c4aee398c82d64849c8bd21f99e2a11 Mon Sep 17 00:00:00 2001 From: Alessandro Morelli Date: Tue, 25 Oct 2022 14:27:15 +0200 Subject: [PATCH] Handle readonly public properties This fixes the handling of readonly public properties by unsetting them and thus making them available for intercept by the magic methods. Cloning, serialization and deserialization are handled accordingly. --- src/Proxy/ProxyGenerator.php | 199 ++++- ...ableObjectWithReadonlyPublicProperties.php | 84 ++ ...hReadonlyPublicPropertiesClassMetadata.php | 169 ++++ tests/Common/Proxy/ProxyGeneratorTest.php | 1 + ...ProxyLogicReadonlyPublicPropertiesTest.php | 729 ++++++++++++++++++ 5 files changed, 1149 insertions(+), 33 deletions(-) create mode 100644 tests/Common/Proxy/LazyLoadableObjectWithReadonlyPublicProperties.php create mode 100644 tests/Common/Proxy/LazyLoadableObjectWithReadonlyPublicPropertiesClassMetadata.php create mode 100644 tests/Common/Proxy/ProxyLogicReadonlyPublicPropertiesTest.php diff --git a/src/Proxy/ProxyGenerator.php b/src/Proxy/ProxyGenerator.php index 8019d418f..0a43d4072 100644 --- a/src/Proxy/ProxyGenerator.php +++ b/src/Proxy/ProxyGenerator.php @@ -143,11 +143,21 @@ class extends \ implements \ properties to be lazy loaded, indexed by property name + */ + public static $identifier = ; + /** * @var array properties to be lazy loaded, indexed by property name */ public static $lazyPropertiesNames = ; + /** + * @var array readonly public properties + */ + public static $readonlyPropertiesNames = ; + /** * @var array default values of properties to be lazy loaded, with keys being the property names * @@ -409,7 +419,7 @@ public function generateEnumUseStatements(ClassMetadata $class): string } $defaultProperties = $class->getReflectionClass()->getDefaultProperties(); - $lazyLoadedPublicProperties = $this->getLazyLoadedPublicPropertiesNames($class); + $lazyLoadedPublicProperties = $this->getWriteableLazyLoadedPublicPropertiesNames($class); $enumClasses = []; foreach ($class->getReflectionClass()->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { @@ -453,7 +463,7 @@ private function generateClassName(ClassMetadata $class) */ private function generateLazyPropertiesNames(ClassMetadata $class) { - $lazyPublicProperties = $this->getLazyLoadedPublicPropertiesNames($class); + $lazyPublicProperties = $this->getWriteableLazyLoadedPublicPropertiesNames($class); $values = []; foreach ($lazyPublicProperties as $name) { @@ -463,6 +473,40 @@ private function generateLazyPropertiesNames(ClassMetadata $class) return var_export($values, true); } + /** + * Generates the array representation of readonly public properties. + * + * @return string + */ + private function generateReadonlyPropertiesNames(ClassMetadata $class) + { + $readonlyPublicProperties = $this->getReadonlyPublicPropertiesNames($class); + $values = []; + + foreach ($readonlyPublicProperties as $name) { + $values[$name] = null; + } + + return var_export($values, true); + } + + /** + * Generates the array representation of readonly public properties. + * + * @return string + */ + private function generateIdentifier(ClassMetadata $class) + { + $identifier = $class->getIdentifier(); + $values = []; + + foreach ($identifier as $name) { + $values[$name] = null; + } + + return var_export($values, true); + } + /** * Generates the array representation of lazy loaded public properties names. * @@ -486,9 +530,19 @@ public function __construct(?\Closure $initializer = null, ?\Closure $cloner = n EOT; + $unsetReadonlyPublicProperties = array_map(static function (string $name): string { + return ' unset($this->' . $name . ');'; + }, $this->getReadonlyPublicPropertiesNames($class)); + + if ($unsetReadonlyPublicProperties !== []) { + $constructorImpl .= ' (function () { unset( ' . + implode(', ', array_map(static fn ($name) => '$this->' . $name, $this->getReadonlyPublicPropertiesNames($class))) . + ' ); } )(...)->bindTo($this, \\' . $class->getName() . '::class)->__invoke();' . "\n"; + } + $toUnset = array_map(static function (string $name): string { return '$this->' . $name; - }, $this->getLazyLoadedPublicPropertiesNames($class)); + }, $this->getWriteableLazyLoadedPublicPropertiesNames($class)); return $constructorImpl . ($toUnset === [] ? '' : ' unset(' . implode(', ', $toUnset) . ");\n") . <<<'EOT' @@ -506,14 +560,15 @@ public function __construct(?\Closure $initializer = null, ?\Closure $cloner = n */ private function generateMagicGet(ClassMetadata $class) { - $lazyPublicProperties = $this->getLazyLoadedPublicPropertiesNames($class); - $reflectionClass = $class->getReflectionClass(); - $hasParentGet = false; - $returnReference = ''; - $inheritDoc = ''; - $name = '$name'; - $parametersString = '$name'; - $returnTypeHint = null; + $lazyPublicProperties = $this->getWriteableLazyLoadedPublicPropertiesNames($class); + $readonlyPublicProperties = $this->getReadonlyPublicPropertiesNames($class); + $reflectionClass = $class->getReflectionClass(); + $hasParentGet = false; + $returnReference = ''; + $inheritDoc = ''; + $name = '$name'; + $parametersString = '$name'; + $returnTypeHint = null; if ($reflectionClass->hasMethod('__get')) { $hasParentGet = true; @@ -531,7 +586,7 @@ private function generateMagicGet(ClassMetadata $class) $returnTypeHint = $this->getMethodReturnType($methodReflection); } - if (empty($lazyPublicProperties) && ! $hasParentGet) { + if (empty($readonlyPublicProperties) && empty($lazyPublicProperties) && ! $hasParentGet) { return ''; } @@ -545,9 +600,9 @@ public function {$returnReference}__get($parametersString)$returnTypeHint EOT; - if (! empty($lazyPublicProperties)) { + if (! empty($lazyPublicProperties) || ! empty($readonlyPublicProperties)) { $magicGet .= <<<'EOT' - if (\array_key_exists($name, self::$lazyPropertiesNames)) { + if (\array_key_exists($name, self::$lazyPropertiesNames) || \array_key_exists($name, self::$readonlyPropertiesNames)) { $this->__initializer__ && $this->__initializer__->__invoke($this, '__get', [$name]); EOT; @@ -605,12 +660,13 @@ public function {$returnReference}__get($parametersString)$returnTypeHint */ private function generateMagicSet(ClassMetadata $class) { - $lazyPublicProperties = $this->getLazyLoadedPublicPropertiesNames($class); - $reflectionClass = $class->getReflectionClass(); - $hasParentSet = false; - $inheritDoc = ''; - $parametersString = '$name, $value'; - $returnTypeHint = null; + $lazyPublicProperties = $this->getWriteableLazyLoadedPublicPropertiesNames($class); + $readonlyPublicProperties = $this->getReadonlyPublicPropertiesNames($class); + $reflectionClass = $class->getReflectionClass(); + $hasParentSet = false; + $inheritDoc = ''; + $parametersString = '$name, $value'; + $returnTypeHint = null; if ($reflectionClass->hasMethod('__set')) { $hasParentSet = true; @@ -621,7 +677,7 @@ private function generateMagicSet(ClassMetadata $class) $returnTypeHint = $this->getMethodReturnType($methodReflection); } - if (empty($lazyPublicProperties) && ! $hasParentSet) { + if (empty($readonlyPublicProperties) && empty($lazyPublicProperties) && ! $hasParentSet) { return ''; } @@ -636,6 +692,21 @@ public function __set($parametersString)$returnTypeHint EOT; + if (! empty($readonlyPublicProperties)) { + $magicSet .= <<\$name ) ) { + trigger_error(sprintf("Cannot modify readonly property {$class->getName()}::%s", \$name), E_USER_ERROR); + } + + (function(string \$name, mixed \$value) { \$this->\$name = \$value; })(...)->bindTo( \$this, \\{$class->getName()}::class)->__invoke(\$name, \$value); + + return; + } +EOT; + } + if (! empty($lazyPublicProperties)) { $magicSet .= <<<'EOT' if (\array_key_exists($name, self::$lazyPropertiesNames)) { @@ -686,10 +757,12 @@ public function __set($parametersString)$returnTypeHint */ private function generateMagicIsset(ClassMetadata $class) { - $lazyPublicProperties = $this->getLazyLoadedPublicPropertiesNames($class); - $hasParentIsset = $class->getReflectionClass()->hasMethod('__isset'); - $parametersString = '$name'; - $returnTypeHint = null; + $lazyPublicProperties = $this->getWriteableLazyLoadedPublicPropertiesNames($class); + $readonlyPublicProperties = $this->getReadonlyPublicPropertiesNames($class); + $identifier = $class->getIdentifier(); + $hasParentIsset = $class->getReflectionClass()->hasMethod('__isset'); + $parametersString = '$name'; + $returnTypeHint = null; if ($hasParentIsset) { $methodReflection = $class->getReflectionClass()->getMethod('__isset'); @@ -697,7 +770,7 @@ private function generateMagicIsset(ClassMetadata $class) $returnTypeHint = $this->getMethodReturnType($methodReflection); } - if (empty($lazyPublicProperties) && ! $hasParentIsset) { + if (empty($readonlyPublicProperties) && empty($lazyPublicProperties) && ! $hasParentIsset) { return ''; } @@ -712,6 +785,17 @@ public function __isset($parametersString)$returnTypeHint { EOT; + if (! empty($readonlyPublicProperties)) { + $magicIsset .= <<<'EOT' + if (\array_key_exists($name, self::$readonlyPropertiesNames) ) { + $this->__initializer__ && !\array_key_exists($name, self::$identifier) && $this->__initializer__->__invoke($this, '__isset', [$name]); + + return isset($this->$name); + } + + +EOT; + } if (! empty($lazyPublicProperties)) { $magicIsset .= <<<'EOT' @@ -765,7 +849,7 @@ public function __sleep()$returnTypeHint $properties = array_merge(['__isInitialized__'], parent::__sleep()); if ($this->__isInitialized__) { - $properties = array_diff($properties, array_keys(self::$lazyPropertiesNames)); + $properties = array_diff($properties, array_keys(self::$lazyPropertiesNames), array_keys(self::$readonlyPropertiesNames)); } return $properties; @@ -786,7 +870,7 @@ public function __sleep()$returnTypeHint : $prop->getName(); } - $lazyPublicProperties = $this->getLazyLoadedPublicPropertiesNames($class); + $lazyPublicProperties = $this->getWriteableLazyLoadedPublicPropertiesNames($class); $protectedProperties = array_diff($allProperties, $lazyPublicProperties); foreach ($allProperties as &$property) { @@ -822,7 +906,7 @@ private function generateWakeupImpl(ClassMetadata $class) $hasParentWakeup = $reflectionClass->hasMethod('__wakeup'); $unsetPublicProperties = []; - foreach ($this->getLazyLoadedPublicPropertiesNames($class) as $lazyPublicProperty) { + foreach ($this->getWriteableLazyLoadedPublicPropertiesNames($class) as $lazyPublicProperty) { $unsetPublicProperties[] = '$this->' . $lazyPublicProperty; } @@ -851,6 +935,14 @@ public function __wakeup()$returnTypeHint EOT; + $unsetReadonlyPublicProperties = array_diff($this->getReadonlyPublicPropertiesNames($class), $class->getIdentifier()); + + if ($unsetReadonlyPublicProperties !== []) { + $wakeupImpl .= ' (function () { unset( ' . + implode(', ', array_map(static fn ($name) => '$this->' . $name, $unsetReadonlyPublicProperties)) . + ' ); } )(...)->bindTo($this, \\' . $class->getName() . '::class)->__invoke();' . "\n"; + } + if (! empty($unsetPublicProperties)) { $wakeupImpl .= "\n unset(" . implode(', ', $unsetPublicProperties) . ');'; } @@ -879,12 +971,28 @@ private function generateCloneImpl(ClassMetadata $class) $inheritDoc = $hasParentClone ? '{@inheritDoc}' : ''; $callParentClone = $hasParentClone ? "\n parent::__clone();\n" : ''; - return <<__isInitialized__) { + +EOT; + $unsetReadonlyPublicProperties = array_diff($this->getReadonlyPublicPropertiesNames($class), $class->getIdentifier()); + + if ($unsetReadonlyPublicProperties !== []) { + $cloner .= ' (function () { unset( ' . + implode(', ', array_map(static fn ($name) => '$this->' . $name, $unsetReadonlyPublicProperties)) . + ' ); } )(...)->bindTo($this, \\' . $class->getName() . '::class)->__invoke();' . "\n"; + } + + return $cloner . <<__cloner__ && \$this->__cloner__->__invoke(\$this, '__clone', []); $callParentClone } EOT; @@ -1026,14 +1134,39 @@ private function isShortIdentifierGetter($method, ClassMetadata $class) * * @return array */ - private function getLazyLoadedPublicPropertiesNames(ClassMetadata $class): array + private function getWriteableLazyLoadedPublicPropertiesNames(ClassMetadata $class): array + { + $properties = []; + + foreach ($class->getReflectionClass()->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + $name = $property->getName(); + + if ( + (! $class->hasField($name) && ! $class->hasAssociation($name)) || $class->isIdentifier($name) + || (method_exists($property, 'isReadonly') && $property->isReadOnly()) + ) { + continue; + } + + $properties[] = $name; + } + + return $properties; + } + + /** + * Generates the list of readonly public properties. + * + * @return array + */ + private function getReadonlyPublicPropertiesNames(ClassMetadata $class): array { $properties = []; foreach ($class->getReflectionClass()->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { $name = $property->getName(); - if ((! $class->hasField($name) && ! $class->hasAssociation($name)) || $class->isIdentifier($name)) { + if ((! method_exists($property, 'isReadonly') || ! $property->isReadOnly())) { continue; } @@ -1051,7 +1184,7 @@ private function getLazyLoadedPublicPropertiesNames(ClassMetadata $class): array private function getLazyLoadedPublicProperties(ClassMetadata $class) { $defaultProperties = $class->getReflectionClass()->getDefaultProperties(); - $lazyLoadedPublicProperties = $this->getLazyLoadedPublicPropertiesNames($class); + $lazyLoadedPublicProperties = $this->getWriteableLazyLoadedPublicPropertiesNames($class); $defaultValues = []; foreach ($class->getReflectionClass()->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { diff --git a/tests/Common/Proxy/LazyLoadableObjectWithReadonlyPublicProperties.php b/tests/Common/Proxy/LazyLoadableObjectWithReadonlyPublicProperties.php new file mode 100644 index 000000000..7d9eeb48f --- /dev/null +++ b/tests/Common/Proxy/LazyLoadableObjectWithReadonlyPublicProperties.php @@ -0,0 +1,84 @@ +publicIdentifierField = $publicIdentifierField; + $this->protectedIdentifierField = $protectedIdentifierField; + $this->publicPersistentField = $publicPersistentField; + $this->publicAssociation = $publicAssociation; + } + + + /** + * @return string + */ + public function getProtectedIdentifierField() + { + return $this->protectedIdentifierField; + } + + /** + * @return string + */ + public function testInitializationTriggeringMethod() + { + return 'testInitializationTriggeringMethod'; + } + + /** + * @return string + */ + public function getProtectedAssociation() + { + return $this->protectedAssociation; + } + + public function publicTypeHintedMethod(stdClass $param) + { + } + + public function &byRefMethod() + { + } + + /** + * @param mixed $thisIsNotByRef + * @param mixed $thisIsByRef + */ + public function byRefParamMethod($thisIsNotByRef, &$thisIsByRef) + { + } +} diff --git a/tests/Common/Proxy/LazyLoadableObjectWithReadonlyPublicPropertiesClassMetadata.php b/tests/Common/Proxy/LazyLoadableObjectWithReadonlyPublicPropertiesClassMetadata.php new file mode 100644 index 000000000..55b438e1e --- /dev/null +++ b/tests/Common/Proxy/LazyLoadableObjectWithReadonlyPublicPropertiesClassMetadata.php @@ -0,0 +1,169 @@ + */ + protected $identifier = [ + 'publicIdentifierField' => true, + 'protectedIdentifierField' => true, + ]; + + /** @var array */ + protected $fields = [ + 'publicIdentifierField' => true, + 'protectedIdentifierField' => true, + 'publicPersistentField' => true, + 'protectedPersistentField' => true, + ]; + + /** @var array */ + protected $associations = [ + 'publicAssociation' => true, + 'protectedAssociation' => true, + ]; + + /** + * {@inheritDoc} + */ + public function getName() + { + return $this->getReflectionClass()->getName(); + } + + /** + * {@inheritDoc} + */ + public function getIdentifier() + { + return array_keys($this->identifier); + } + + /** + * {@inheritDoc} + */ + public function getReflectionClass() + { + if ($this->reflectionClass === null) { + $this->reflectionClass = new ReflectionClass(__NAMESPACE__ . '\LazyLoadableObjectWithReadonlyPublicProperties'); + } + + return $this->reflectionClass; + } + + /** + * {@inheritDoc} + */ + public function isIdentifier($fieldName) + { + return isset($this->identifier[$fieldName]); + } + + /** + * {@inheritDoc} + */ + public function hasField($fieldName) + { + return isset($this->fields[$fieldName]); + } + + /** + * {@inheritDoc} + */ + public function hasAssociation($fieldName) + { + return isset($this->associations[$fieldName]); + } + + /** + * {@inheritDoc} + */ + public function isSingleValuedAssociation($fieldName) + { + throw new BadMethodCallException('not implemented'); + } + + /** + * {@inheritDoc} + */ + public function isCollectionValuedAssociation($fieldName) + { + throw new BadMethodCallException('not implemented'); + } + + /** + * {@inheritDoc} + */ + public function getFieldNames() + { + return array_keys($this->fields); + } + + /** + * {@inheritDoc} + */ + public function getIdentifierFieldNames() + { + return $this->getIdentifier(); + } + + /** + * {@inheritDoc} + */ + public function getAssociationNames() + { + return array_keys($this->associations); + } + + /** + * {@inheritDoc} + */ + public function getTypeOfField($fieldName) + { + return 'string'; + } + + /** + * {@inheritDoc} + */ + public function getAssociationTargetClass($assocName) + { + throw new BadMethodCallException('not implemented'); + } + + /** + * {@inheritDoc} + */ + public function isAssociationInverseSide($assocName) + { + throw new BadMethodCallException('not implemented'); + } + + /** + * {@inheritDoc} + */ + public function getAssociationMappedByTargetField($assocName) + { + throw new BadMethodCallException('not implemented'); + } + + /** + * {@inheritDoc} + */ + public function getIdentifierValues($object) + { + throw new BadMethodCallException('not implemented'); + } +} diff --git a/tests/Common/Proxy/ProxyGeneratorTest.php b/tests/Common/Proxy/ProxyGeneratorTest.php index 59c1eb3ef..81650d435 100644 --- a/tests/Common/Proxy/ProxyGeneratorTest.php +++ b/tests/Common/Proxy/ProxyGeneratorTest.php @@ -646,6 +646,7 @@ private function createClassMetadata($className, array $ids) $reflClass = new ReflectionClass($className); $metadata->expects($this->any())->method('getReflectionClass')->will($this->returnValue($reflClass)); $metadata->expects($this->any())->method('getIdentifierFieldNames')->will($this->returnValue($ids)); + $metadata->expects($this->any())->method('getIdentifier')->will($this->returnValue($ids)); $metadata->expects($this->any())->method('getName')->will($this->returnValue($className)); return $metadata; diff --git a/tests/Common/Proxy/ProxyLogicReadonlyPublicPropertiesTest.php b/tests/Common/Proxy/ProxyLogicReadonlyPublicPropertiesTest.php new file mode 100644 index 000000000..ddbfa97e3 --- /dev/null +++ b/tests/Common/Proxy/ProxyLogicReadonlyPublicPropertiesTest.php @@ -0,0 +1,729 @@ + */ + protected $identifier = [ + 'publicIdentifierField' => 'publicIdentifierFieldValue', + 'protectedIdentifierField' => 'protectedIdentifierFieldValue', + ]; + + /** @var MockObject&Callable */ + protected $initializerCallbackMock; + + /** + * {@inheritDoc} + */ + public function setUp() : void + { + $loader = $this->proxyLoader = $this->createMock(RProxyLoader::class); + $this->initializerCallbackMock = $this->getMockBuilder(stdClass::class)->setMethods(['__invoke'])->getMock(); + $identifier = $this->identifier; + $this->lazyLoadableObjectMetadata = $metadata = new LazyLoadableObjectWithReadonlyPublicPropertiesClassMetadata(); + + // emulating what should happen in a proxy factory + $cloner = static function (LazyLoadableObjectWithReadonlyPublicProperties $proxy) use ($loader, $identifier, $metadata) { + /** @var LazyLoadableObjectWithReadonlyPublicProperties&Proxy $proxy */ + $proxy = $proxy; + if ($proxy->__isInitialized()) { + return; + } + + $proxy->__setInitialized(true); + $proxy->__setInitializer(null); + $original = $loader->load($identifier); + + if ($original === null) { + throw new UnexpectedValueException(); + } + + $identifierFields = $metadata->getIdentifier(); + + foreach ($metadata->getReflectionClass()->getProperties() as $reflProperty) { + $propertyName = $reflProperty->getName(); + + if (in_array($propertyName, $identifierFields) || (! $metadata->hasField($propertyName) && ! $metadata->hasAssociation($propertyName)) ) { + continue; + } + + $reflProperty->setAccessible(true); + $reflProperty->setValue($proxy, $reflProperty->getValue($original)); + } + }; + + $proxyClassName = 'Doctrine\Tests\Common\ProxyProxy\__CG__\Doctrine\Tests\Common\Proxy\LazyLoadableObjectWithReadonlyPublicProperties'; + + // creating the proxy class + if (! class_exists($proxyClassName, false)) { + $proxyGenerator = new ProxyGenerator(__DIR__ . '/generated', __NAMESPACE__ . 'Proxy'); + $proxyFileName = $proxyGenerator->getProxyFileName($metadata->getName()); + $proxyGenerator->generateProxyClass($metadata, $proxyFileName); + require_once $proxyFileName; + } + + $this->lazyObject = new $proxyClassName($this->getClosure($this->initializerCallbackMock), $cloner); + + // setting identifiers in the proxy via reflection + foreach ($metadata->getIdentifierFieldNames() as $idField) { + $prop = $metadata->getReflectionClass()->getProperty($idField); + $prop->setAccessible(true); + $prop->setValue($this->lazyObject, $identifier[$idField]); + } + + self::assertFalse($this->lazyObject->__isInitialized()); + } + + public function testFetchingPublicIdentifierDoesNotCauseLazyLoading() + { + $this->configureInitializerMock(0); + + self::assertSame('publicIdentifierFieldValue', $this->lazyObject->publicIdentifierField); + } + + public function testFetchingIdentifiersViaPublicGetterDoesNotCauseLazyLoading() + { + $this->configureInitializerMock(0); + + self::assertSame('protectedIdentifierFieldValue', $this->lazyObject->getProtectedIdentifierField()); + } + + public function testCallingMethodCausesLazyLoading() + { + $this->configureInitializerMock( + 1, + [$this->lazyObject, 'testInitializationTriggeringMethod', []], + static function (Proxy $proxy) { + $proxy->__setInitializer(null); + } + ); + + $this->lazyObject->testInitializationTriggeringMethod(); + $this->lazyObject->testInitializationTriggeringMethod(); + } + + public function testFetchingPublicFieldsCausesLazyLoading() + { + $test = $this; + $this->configureInitializerMock( + 1, + [$this->lazyObject, '__get', ['publicPersistentField']], + static function () use ($test) { + $test->setProxyValue('publicPersistentField', 'loadedValue'); + } + ); + + self::assertSame('loadedValue', $this->lazyObject->publicPersistentField); + self::assertSame('loadedValue', $this->lazyObject->publicPersistentField); + } + + public function testFetchingPublicAssociationCausesLazyLoading() + { + $test = $this; + $this->configureInitializerMock( + 1, + [$this->lazyObject, '__get', ['publicAssociation']], + static function () use ($test) { + $test->setProxyValue('publicAssociation', 'loadedAssociation'); + } + ); + + self::assertSame('loadedAssociation', $this->lazyObject->publicAssociation); + self::assertSame('loadedAssociation', $this->lazyObject->publicAssociation); + } + + public function testFetchingProtectedAssociationViaPublicGetterCausesLazyLoading() + { + $this->configureInitializerMock( + 1, + [$this->lazyObject, 'getProtectedAssociation', []], + static function (Proxy $proxy) { + $proxy->__setInitializer(null); + } + ); + + self::assertSame('protectedAssociationValue', $this->lazyObject->getProtectedAssociation()); + self::assertSame('protectedAssociationValue', $this->lazyObject->getProtectedAssociation()); + } + + public function testLazyLoadingTriggeredOnlyAtFirstPublicPropertyRead() + { + $test = $this; + $this->configureInitializerMock( + 1, + [$this->lazyObject, '__get', ['publicPersistentField']], + static function () use ($test) { + $test->setProxyValue('publicPersistentField', 'loadedValue'); + $test->setProxyValue('publicAssociation', 'publicAssociationValue'); + } + ); + + self::assertSame('loadedValue', $this->lazyObject->publicPersistentField); + self::assertSame('publicAssociationValue', $this->lazyObject->publicAssociation); + } + + public function testNoticeWhenReadingNonExistentPublicProperties() + { + $this->configureInitializerMock(0); + + $class = get_class($this->lazyObject); + // @todo drop condition when PHPUnit 9.x becomes lowest + if (method_exists($this, 'expectNotice')) { + $this->expectNotice(); + $this->expectNoticeMessage('Undefined property: ' . $class . '::$non_existing_property'); + } else { + $this->expectException(Notice::class); + $this->expectExceptionMessage('Undefined property: ' . $class . '::$non_existing_property'); + } + + $this->lazyObject->non_existing_property; + } + + public function testFalseWhenCheckingNonExistentProperty() + { + $this->configureInitializerMock(0); + + self::assertFalse(isset($this->lazyObject->non_existing_property)); + } + + public function testNoErrorWhenSettingNonExistentProperty() + { + if (PHP_VERSION_ID >= 80200) { + $this->markTestSkipped('access to a dynamic property trigger a deprecation notice on PHP 8.2+'); + } + + $this->configureInitializerMock(0); + + $this->lazyObject->non_existing_property = 'now has a value'; + self::assertSame('now has a value', $this->lazyObject->non_existing_property); + } + + public function testCloningCallsClonerWithClonedObject() + { + $lazyObject = $this->lazyObject; + $test = $this; + $callback = static function (LazyLoadableObjectWithReadonlyPublicProperties $proxy) use ($lazyObject, $test) { + assert($proxy instanceof Proxy); + $test->assertNotSame($proxy, $lazyObject); + $proxy->__setInitializer(null); + $proxy->publicAssociation = 'clonedAssociation'; + }; + $cb = $this->createMock(RCloner::class); + $cb + ->expects($this->once()) + ->method('cb') + ->will($this->returnCallback($callback)); + + $this->lazyObject->__setCloner($this->getClosure([$cb, 'cb'])); + + $cloned = clone $this->lazyObject; + self::assertSame('clonedAssociation', $cloned->publicAssociation); + self::assertNotSame($cloned, $lazyObject, 'a clone of the lazy object is retrieved'); + } + + public function cb() + { + } + + public function testFetchingTransientPropertiesWillNotTriggerLazyLoading() + { + $this->configureInitializerMock(0); + + self::assertSame( + 'publicTransientFieldValue', + $this->lazyObject->publicTransientField, + 'fetching public transient field won\'t trigger lazy loading' + ); + $property = $this + ->lazyLoadableObjectMetadata + ->getReflectionClass() + ->getProperty('protectedTransientField'); + $property->setAccessible(true); + self::assertSame( + 'protectedTransientFieldValue', + $property->getValue($this->lazyObject), + 'fetching protected transient field via reflection won\'t trigger lazy loading' + ); + } + + /** + * Provided to guarantee backwards compatibility + */ + public function testLoadProxyMethod() + { + $this->configureInitializerMock(2, [$this->lazyObject, '__load', []]); + + $this->lazyObject->__load(); + $this->lazyObject->__load(); + } + + public function testLoadingWithPersisterWillBeTriggeredOnlyOnce() + { + $this + ->proxyLoader + ->expects($this->once()) + ->method('load') + ->with( + [ + 'publicIdentifierField' => 'publicIdentifierFieldValue', + 'protectedIdentifierField' => 'protectedIdentifierFieldValue', + ], + $this->lazyObject + ) + ->will($this->returnCallback(static function ($id, LazyLoadableObjectWithReadonlyPublicProperties $lazyObject) { + // setting a value to verify that the persister can actually set something in the object + $lazyObject->publicAssociation = $id['publicIdentifierField'] . '-test'; + + return true; + })); + $this->lazyObject->__setInitializer($this->getSuggestedInitializerImplementation()); + + $this->lazyObject->__load(); + $this->lazyObject->__load(); + self::assertSame('publicIdentifierFieldValue-test', $this->lazyObject->publicAssociation); + } + + public function testFailedLoadingWillThrowException() + { + $this->proxyLoader->expects($this->any())->method('load')->will($this->returnValue(null)); + $this->expectException(\UnexpectedValueException::class); + $this->lazyObject->__setInitializer($this->getSuggestedInitializerImplementation()); + + $this->lazyObject->__load(); + } + + public function testCloningWithPersister() + { + $this->lazyObject->publicTransientField = 'should-not-change'; + $this + ->proxyLoader + ->expects($this->exactly(2)) + ->method('load') + ->with([ + 'publicIdentifierField' => 'publicIdentifierFieldValue', + 'protectedIdentifierField' => 'protectedIdentifierFieldValue', + ]) + ->will($this->returnCallback(static function () { + $blueprint = new LazyLoadableObjectWithReadonlyPublicProperties( + 'publicIdentifierFieldValue', + 'protectedIdentifierFieldValue', + 'checked-persistent-field', + 'checked-association-field' + ); + $blueprint->publicTransientField = 'checked-transient-field'; + + return $blueprint; + })); + + $firstClone = clone $this->lazyObject; + self::assertSame( + 'checked-persistent-field', + $firstClone->publicPersistentField, + 'Persistent fields are cloned correctly' + ); + self::assertSame( + 'checked-association-field', + $firstClone->publicAssociation, + 'Associations are cloned correctly' + ); + self::assertSame( + 'should-not-change', + $firstClone->publicTransientField, + 'Transient fields are not overwritten' + ); + + $secondClone = clone $this->lazyObject; + self::assertSame( + 'checked-persistent-field', + $secondClone->publicPersistentField, + 'Persistent fields are cloned correctly' + ); + self::assertSame( + 'checked-association-field', + $secondClone->publicAssociation, + 'Associations are cloned correctly' + ); + self::assertSame( + 'should-not-change', + $secondClone->publicTransientField, + 'Transient fields are not overwritten' + ); + + // those should not trigger lazy loading + $firstClone->__load(); + $secondClone->__load(); + } + + public function testNotInitializedProxyUnserialization() + { + $this->configureInitializerMock(); + + $serialized = serialize($this->lazyObject); + /** @var LazyLoadableObjectWithReadonlyPublicProperties&Proxy $unserialized */ + $unserialized = unserialize($serialized); + $reflClass = $this->lazyLoadableObjectMetadata->getReflectionClass(); + + self::assertFalse($unserialized->__isInitialized(), 'serialization didn\'t cause initialization'); + + // Checking identifiers + self::assertSame('publicIdentifierFieldValue', $unserialized->publicIdentifierField, 'identifiers are kept'); + $protectedIdentifierField = $reflClass->getProperty('protectedIdentifierField'); + $protectedIdentifierField->setAccessible(true); + self::assertSame( + 'protectedIdentifierFieldValue', + $protectedIdentifierField->getValue($unserialized), + 'identifiers are kept' + ); + + // Checking transient fields + self::assertSame( + 'publicTransientFieldValue', + $unserialized->publicTransientField, + 'transient fields are kept' + ); + $protectedTransientField = $reflClass->getProperty('protectedTransientField'); + $protectedTransientField->setAccessible(true); + self::assertSame( + 'protectedTransientFieldValue', + $protectedTransientField->getValue($unserialized), + 'transient fields are kept' + ); + + $protectedPersistentField = $reflClass->getProperty('protectedPersistentField'); + $protectedPersistentField->setAccessible(true); + self::assertSame( + 'protectedPersistentFieldValue', + $protectedPersistentField->getValue($unserialized), + 'persistent fields are kept' + ); + } + + public function testInitializedProxyUnserialization() + { + + $test = $this; + $this + ->proxyLoader + ->expects($this->once()) + ->method('load') + ->with( + [ + 'publicIdentifierField' => 'publicIdentifierFieldValue', + 'protectedIdentifierField' => 'protectedIdentifierFieldValue', + ], + $this->lazyObject + ) + ->will($this->returnCallback(static function ($id, LazyLoadableObjectWithReadonlyPublicProperties $lazyObject) use($test) { + // setting a value to verify that the persister can actually set something in the object + $test->setProxyValue('publicPersistentField', 'publicPersistentFieldValue'); + $test->setProxyValue('publicAssociation', 'publicAssociationValue'); + return true; + })); + + + $this->lazyObject->__setInitializer($this->getSuggestedInitializerImplementation()); + $this->lazyObject->__load(); + + $serialized = serialize($this->lazyObject); + $reflClass = $this->lazyLoadableObjectMetadata->getReflectionClass(); + /** @var LazyLoadableObjectWithReadonlyPublicProperties&Proxy $unserialized */ + $unserialized = unserialize($serialized); + + self::assertTrue($unserialized->__isInitialized(), 'serialization didn\'t cause initialization'); + + // Checking transient fields + self::assertSame( + 'publicTransientFieldValue', + $unserialized->publicTransientField, + 'transient fields are kept' + ); + $protectedTransientField = $reflClass->getProperty('protectedTransientField'); + $protectedTransientField->setAccessible(true); + self::assertSame( + 'protectedTransientFieldValue', + $protectedTransientField->getValue($unserialized), + 'transient fields are kept' + ); + + // Checking persistent fields + self::assertSame( + 'publicPersistentFieldValue', + $unserialized->publicPersistentField, + 'persistent fields are kept' + ); + $protectedPersistentField = $reflClass->getProperty('protectedPersistentField'); + $protectedPersistentField->setAccessible(true); + self::assertSame( + 'protectedPersistentFieldValue', + $protectedPersistentField->getValue($unserialized), + 'persistent fields are kept' + ); + + // Checking identifiers + self::assertSame( + 'publicIdentifierFieldValue', + $unserialized->publicIdentifierField, + 'identifiers are kept' + ); + $protectedIdentifierField = $reflClass->getProperty('protectedIdentifierField'); + $protectedIdentifierField->setAccessible(true); + self::assertSame( + 'protectedIdentifierFieldValue', + $protectedIdentifierField->getValue($unserialized), + 'identifiers are kept' + ); + + // Checking associations + self::assertSame('publicAssociationValue', $unserialized->publicAssociation, 'associations are kept'); + $protectedAssociationField = $reflClass->getProperty('protectedAssociation'); + $protectedAssociationField->setAccessible(true); + self::assertSame( + 'protectedAssociationValue', + $protectedAssociationField->getValue($unserialized), + 'associations are kept' + ); + } + + public function testCheckingPublicFieldsCausesLazyLoading() + { + $test = $this; + $this->configureInitializerMock( + 1, + [$this->lazyObject, '__isset', ['publicPersistentField']], + static function () use ($test) { + $test->setProxyValue('publicPersistentField', null); + $test->setProxyValue('publicAssociation', 'setPublicAssociation'); + } + ); + + self::assertFalse(isset($this->lazyObject->publicPersistentField)); + self::assertNull($this->lazyObject->publicPersistentField); + self::assertTrue(isset($this->lazyObject->publicAssociation)); + self::assertSame('setPublicAssociation', $this->lazyObject->publicAssociation); + } + + public function testCheckingPublicAssociationCausesLazyLoading() + { + $test = $this; + $this->configureInitializerMock( + 1, + [$this->lazyObject, '__isset', ['publicAssociation']], + static function () use ($test) { + $test->setProxyValue('publicPersistentField', 'newPersistentFieldValue'); + $test->setProxyValue('publicAssociation', 'setPublicAssociation'); + } + ); + + self::assertTrue(isset($this->lazyObject->publicAssociation)); + self::assertSame('setPublicAssociation', $this->lazyObject->publicAssociation); + self::assertTrue(isset($this->lazyObject->publicPersistentField)); + self::assertSame('newPersistentFieldValue', $this->lazyObject->publicPersistentField); + } + + public function testCallingVariadicMethodCausesLazyLoading() + { + $proxyClassName = 'Doctrine\Tests\Common\ProxyProxy\__CG__\Doctrine\Tests\Common\Proxy\VariadicTypeHintClass'; + + /** @var ClassMetadata&MockObject $metadata */ + $metadata = $this->createMock(ClassMetadata::class); + + $metadata + ->expects($this->any()) + ->method('getName') + ->will($this->returnValue(VariadicTypeHintClass::class)); + $metadata + ->expects($this->any()) + ->method('getReflectionClass') + ->will($this->returnValue(new ReflectionClass(VariadicTypeHintClass::class))); + $metadata + ->expects($this->any()) + ->method('getIdentifier') + ->will($this->returnValue([])); + + // creating the proxy class + if (! class_exists($proxyClassName, false)) { + $proxyGenerator = new ProxyGenerator(__DIR__ . '/generated', __NAMESPACE__ . 'Proxy'); + $proxyGenerator->generateProxyClass($metadata, $proxyGenerator->getProxyFileName($metadata->getName())); + require_once $proxyGenerator->getProxyFileName($metadata->getName()); + } + + $invocationMock = new InvokationSpy(); + + /** @var VariadicTypeHintClass $lazyObject */ + $lazyObject = new $proxyClassName( + static function ($proxy, $method, $parameters) use ($invocationMock) { + $invocationMock($proxy, $method, $parameters); + }, + static function () { + } + ); + + $lazyObject->addType('type1', 'type2'); + self::assertCount(1, $invocationMock->invokations); + self::assertSame([$lazyObject, 'addType', [['type1', 'type2']]], $invocationMock->invokations[0]); + self::assertSame(['type1', 'type2'], $lazyObject->types); + + $lazyObject->addTypeWithMultipleParameters('foo', 'bar', 'baz1', 'baz2'); + self::assertCount(2, $invocationMock->invokations); + self::assertSame( + [$lazyObject, 'addTypeWithMultipleParameters', ['foo', 'bar', ['baz1', 'baz2']]], + $invocationMock->invokations[1] + ); + self::assertSame('foo', $lazyObject->foo); + self::assertSame('bar', $lazyObject->bar); + self::assertSame(['baz1', 'baz2'], $lazyObject->baz); + } + + /** + * Converts a given callable into a closure + * + * @param callable $callable + * + * @return Closure + */ + public function getClosure($callable) + { + return static function () use ($callable) { + call_user_func_array($callable, func_get_args()); + }; + } + + /** + * Configures the current initializer callback mock with provided matcher params + * + * @param int $expectedCallCount the number of invocations to be expected. If a value< 0 is provided, `any` is used + * @param mixed[] $callParamsMatch an ordered array of parameters to be expected + * @param Closure $callbackClosure a return callback closure + * + * @return void + */ + protected function configureInitializerMock( + $expectedCallCount = 0, + ?array $callParamsMatch = null, + ?Closure $callbackClosure = null + ) { + if (! $expectedCallCount) { + $invocationCountMatcher = $this->exactly((int) $expectedCallCount); + } else { + $invocationCountMatcher = $expectedCallCount < 0 ? $this->any() : $this->exactly($expectedCallCount); + } + + $invocationMocker = $this->initializerCallbackMock->expects($invocationCountMatcher)->method('__invoke'); + + if ($callParamsMatch !== null) { + call_user_func_array([$invocationMocker, 'with'], $callParamsMatch); + } + + if (! $callbackClosure) { + return; + } + + $invocationMocker->will($this->returnCallback($callbackClosure)); + } + + /** + * Sets a value in the current proxy object without triggering lazy loading through `__set` + * + * @link https://bugs.php.net/bug.php?id=63463 + * + * @param string $property + * @param mixed $value + */ + public function setProxyValue($property, $value) + { + $reflectionProperty = new ReflectionProperty($this->lazyObject, $property); + $initializer = $this->lazyObject->__getInitializer(); + + // disabling initializer since setting `publicPersistentField` triggers `__set`/`__get` + $this->lazyObject->__setInitializer(null); + $reflectionProperty->setValue($this->lazyObject, $value); + $this->lazyObject->__setInitializer($initializer); + } + + /** + * Retrieves the suggested implementation of an initializer that proxy factories in O*M + * are currently following, and that should be used to initialize the current proxy object + * + * @return Closure + */ + protected function getSuggestedInitializerImplementation() + { + $loader = $this->proxyLoader; + $identifier = $this->identifier; + + return static function (LazyLoadableObjectWithReadonlyPublicProperties $proxy) use ($loader, $identifier) { + /** @var LazyLoadableObjectWithReadonlyPublicProperties&Proxy $proxy */ + $proxy = $proxy; + $proxy->__setInitializer(null); + $proxy->__setCloner(null); + + if ($proxy->__isInitialized()) { + return; + } + + $properties = $proxy->__getLazyProperties(); + + foreach ($properties as $propertyName => $property) { + if (isset($proxy->$propertyName)) { + continue; + } + + $proxy->$propertyName = $properties[$propertyName]; + } + + $proxy->__setInitialized(true); + + if (method_exists($proxy, '__wakeup')) { + $proxy->__wakeup(); + } + + if ($loader->load($identifier, $proxy) === null) { + throw new \UnexpectedValueException('Couldn\'t load'); + } + }; + } +} + +interface RCloner +{ + public function cb() : ?callable; +} + +interface RProxyLoader +{ + /** @return mixed */ + public function load(...$args); +}