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); +}