From 2979513b2eac7df13743f77d82193b9a18a1004e Mon Sep 17 00:00:00 2001 From: Marco Pivetta Date: Wed, 7 Nov 2012 06:31:56 +0100 Subject: [PATCH] Implementing generator/factory for lazy loading proxies --- .gitignore | 1 + .travis.yml | 1 + .../Mapping/RuntimeReflectionService.php | 53 +- lib/Doctrine/Common/Persistence/Proxy.php | 2 +- lib/Doctrine/Common/Proxy/Autoloader.php | 93 ++ .../Exception/InvalidArgumentException.php | 81 ++ .../Common/Proxy/Exception/ProxyException.php | 31 + .../Exception/UnexpectedValueException.php | 61 ++ lib/Doctrine/Common/Proxy/Proxy.php | 83 ++ lib/Doctrine/Common/Proxy/ProxyGenerator.php | 921 ++++++++++++++++++ .../RuntimePublicReflectionProperty.php | 76 ++ .../Mapping/RuntimeReflectionServiceTest.php | 8 + .../Tests/Common/Proxy/AutoloaderTest.php | 72 ++ .../Common/Proxy/InvalidTypeHintClass.php | 16 + .../Tests/Common/Proxy/LazyLoadableObject.php | 115 +++ .../Proxy/LazyLoadableObjectClassMetadata.php | 195 ++++ .../Common/Proxy/ProxyClassGeneratorTest.php | 161 +++ .../Tests/Common/Proxy/ProxyLogicTest.php | 696 +++++++++++++ .../Tests/Common/Proxy/SleepClass.php | 19 + .../RuntimePublicReflectionPropertyTest.php | 192 ++++ 20 files changed, 2845 insertions(+), 32 deletions(-) create mode 100644 lib/Doctrine/Common/Proxy/Autoloader.php create mode 100644 lib/Doctrine/Common/Proxy/Exception/InvalidArgumentException.php create mode 100644 lib/Doctrine/Common/Proxy/Exception/ProxyException.php create mode 100644 lib/Doctrine/Common/Proxy/Exception/UnexpectedValueException.php create mode 100644 lib/Doctrine/Common/Proxy/Proxy.php create mode 100644 lib/Doctrine/Common/Proxy/ProxyGenerator.php create mode 100644 lib/Doctrine/Common/Reflection/RuntimePublicReflectionProperty.php create mode 100644 tests/Doctrine/Tests/Common/Proxy/AutoloaderTest.php create mode 100644 tests/Doctrine/Tests/Common/Proxy/InvalidTypeHintClass.php create mode 100644 tests/Doctrine/Tests/Common/Proxy/LazyLoadableObject.php create mode 100644 tests/Doctrine/Tests/Common/Proxy/LazyLoadableObjectClassMetadata.php create mode 100644 tests/Doctrine/Tests/Common/Proxy/ProxyClassGeneratorTest.php create mode 100644 tests/Doctrine/Tests/Common/Proxy/ProxyLogicTest.php create mode 100644 tests/Doctrine/Tests/Common/Proxy/SleepClass.php create mode 100644 tests/Doctrine/Tests/Common/Reflection/RuntimePublicReflectionPropertyTest.php diff --git a/.gitignore b/.gitignore index fb5e79d65..5080781ef 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ build/ logs/ reports/ dist/ +tests/Doctrine/Tests/Common/Proxy/generated/ diff --git a/.travis.yml b/.travis.yml index fc0505634..6d22d29c9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ env: - OPCODE_CACHE=apc php: + - 5.3.3 - 5.3 - 5.4 diff --git a/lib/Doctrine/Common/Persistence/Mapping/RuntimeReflectionService.php b/lib/Doctrine/Common/Persistence/Mapping/RuntimeReflectionService.php index 77b9e7606..b00764385 100644 --- a/lib/Doctrine/Common/Persistence/Mapping/RuntimeReflectionService.php +++ b/lib/Doctrine/Common/Persistence/Mapping/RuntimeReflectionService.php @@ -21,6 +21,7 @@ use ReflectionClass; use ReflectionProperty; +use Doctrine\Common\Reflection\RuntimePublicReflectionProperty; /** * PHP Runtime Reflection Service @@ -30,10 +31,7 @@ class RuntimeReflectionService implements ReflectionService { /** - * Return an array of the parent classes (not interfaces) for the given class. - * - * @param string $class - * @return array + * {@inheritDoc} */ public function getParentClasses($class) { @@ -41,32 +39,27 @@ public function getParentClasses($class) } /** - * Return the shortname of a class. - * - * @param string $class - * @return string + * {@inheritDoc} */ public function getClassShortName($class) { - $r = new ReflectionClass($class); - return $r->getShortName(); + $reflectionClass = new ReflectionClass($class); + + return $reflectionClass->getShortName(); } /** - * @param string $class - * @return string + * {@inheritDoc} */ public function getClassNamespace($class) { - $r = new ReflectionClass($class); - return $r->getNamespaceName(); + $reflectionClass = new ReflectionClass($class); + + return $reflectionClass->getNamespaceName(); } /** - * Return a reflection class instance or null - * - * @param string $class - * @return ReflectionClass|null + * {@inheritDoc} */ public function getClass($class) { @@ -74,25 +67,23 @@ public function getClass($class) } /** - * Return an accessible property (setAccessible(true)) or null. - * - * @param string $class - * @param string $property - * @return ReflectionProperty|null + * {@inheritDoc} */ public function getAccessibleProperty($class, $property) { - $property = new ReflectionProperty($class, $property); - $property->setAccessible(true); - return $property; + $reflectionProperty = new ReflectionProperty($class, $property); + + if ($reflectionProperty->isPublic()) { + $reflectionProperty = new RuntimePublicReflectionProperty($class, $property); + } + + $reflectionProperty->setAccessible(true); + + return $reflectionProperty; } /** - * Check if the class have a public method with the given name. - * - * @param mixed $class - * @param mixed $method - * @return bool + * {@inheritDoc} */ public function hasPublicMethod($class, $method) { diff --git a/lib/Doctrine/Common/Persistence/Proxy.php b/lib/Doctrine/Common/Persistence/Proxy.php index e25598c67..9c1bf5f06 100644 --- a/lib/Doctrine/Common/Persistence/Proxy.php +++ b/lib/Doctrine/Common/Persistence/Proxy.php @@ -38,7 +38,7 @@ interface Proxy /** * Length of the proxy marker * - * @var int + * @var integer */ const MARKER_LENGTH = 6; diff --git a/lib/Doctrine/Common/Proxy/Autoloader.php b/lib/Doctrine/Common/Proxy/Autoloader.php new file mode 100644 index 000000000..4859a7721 --- /dev/null +++ b/lib/Doctrine/Common/Proxy/Autoloader.php @@ -0,0 +1,93 @@ +. + */ + +namespace Doctrine\Common\Proxy; + +use Doctrine\Common\Proxy\Exception\InvalidArgumentException; + +/** + * Special Autoloader for Proxy classes, which are not PSR-0 compliant. + * + * @author Benjamin Eberlei + */ +class Autoloader +{ + /** + * Resolves proxy class name to a filename based on the following pattern. + * + * 1. Remove Proxy namespace from class name + * 2. Remove namespace separators from remaining class name. + * 3. Return PHP filename from proxy-dir with the result from 2. + * + * @param string $proxyDir + * @param string $proxyNamespace + * @param string $className + * + * @return string + * + * @throws InvalidArgumentException + */ + public static function resolveFile($proxyDir, $proxyNamespace, $className) + { + if (0 !== strpos($className, $proxyNamespace)) { + throw InvalidArgumentException::notProxyClass($className, $proxyNamespace); + } + + $className = str_replace('\\', '', substr($className, strlen($proxyNamespace) + 1)); + + return $proxyDir . DIRECTORY_SEPARATOR . $className . '.php'; + } + + /** + * Register and return autoloader callback for the given proxy dir and + * namespace. + * + * @param string $proxyDir + * @param string $proxyNamespace + * @param callable $notFoundCallback Invoked when the proxy file is not found. + * + * @return \Closure + * + * @throws InvalidArgumentException + */ + public static function register($proxyDir, $proxyNamespace, $notFoundCallback = null) + { + $proxyNamespace = ltrim($proxyNamespace, '\\'); + + if ( ! (null === $notFoundCallback || is_callable($notFoundCallback))) { + throw InvalidArgumentException::invalidClassNotFoundCallback($notFoundCallback); + } + + $autoloader = function ($className) use ($proxyDir, $proxyNamespace, $notFoundCallback) { + if (0 === strpos($className, $proxyNamespace)) { + $file = Autoloader::resolveFile($proxyDir, $proxyNamespace, $className); + + if ($notFoundCallback && ! file_exists($file)) { + call_user_func($notFoundCallback, $proxyDir, $proxyNamespace, $className); + } + + require $file; + } + }; + + spl_autoload_register($autoloader); + + return $autoloader; + } +} diff --git a/lib/Doctrine/Common/Proxy/Exception/InvalidArgumentException.php b/lib/Doctrine/Common/Proxy/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..e5ba68d59 --- /dev/null +++ b/lib/Doctrine/Common/Proxy/Exception/InvalidArgumentException.php @@ -0,0 +1,81 @@ +. + */ + +namespace Doctrine\Common\Proxy\Exception; + +use InvalidArgumentException as BaseInvalidArgumentException; + +/** + * Proxy Invalid Argument Exception + * + * @link www.doctrine-project.com + * @since 2.4 + * @author Marco Pivetta + */ +class InvalidArgumentException extends BaseInvalidArgumentException implements ProxyException +{ + /** + * @return self + */ + public static function proxyDirectoryRequired() + { + return new self('You must configure a proxy directory. See docs for details'); + } + + /** + * @param string $className + * @param string $proxyNamespace + * + * @return self + */ + public static function notProxyClass($className, $proxyNamespace) + { + return new self(sprintf('The class "%s" is not part of the proxy namespace "%s"', $className, $proxyNamespace)); + } + + /** + * @param string $name + * + * @return self + */ + public static function invalidPlaceholder($name) + { + return new self(sprintf('Provided placeholder for "%s" must be either a string or a valid callable', $name)); + } + + /** + * @return self + */ + public static function proxyNamespaceRequired() + { + return new self('You must configure a proxy namespace'); + } + + /** + * @param mixed $callback + * + * @return self + */ + public static function invalidClassNotFoundCallback($callback) + { + $type = is_object($callback) ? get_class($callback) : gettype($callback); + + return new self(sprintf('Invalid \$notFoundCallback given: must be a callable, "%s" given', $type)); + } +} diff --git a/lib/Doctrine/Common/Proxy/Exception/ProxyException.php b/lib/Doctrine/Common/Proxy/Exception/ProxyException.php new file mode 100644 index 000000000..7fa747c1b --- /dev/null +++ b/lib/Doctrine/Common/Proxy/Exception/ProxyException.php @@ -0,0 +1,31 @@ +. + */ + +namespace Doctrine\Common\Proxy\Exception; + +/** + * Base exception interface for proxy exceptions + * + * @link www.doctrine-project.com + * @since 2.4 + * @author Marco Pivetta + */ +interface ProxyException +{ +} diff --git a/lib/Doctrine/Common/Proxy/Exception/UnexpectedValueException.php b/lib/Doctrine/Common/Proxy/Exception/UnexpectedValueException.php new file mode 100644 index 000000000..a466a7222 --- /dev/null +++ b/lib/Doctrine/Common/Proxy/Exception/UnexpectedValueException.php @@ -0,0 +1,61 @@ +. + */ + +namespace Doctrine\Common\Proxy\Exception; + +use UnexpectedValueException as BaseUnexpectedValueException; + +/** + * Proxy Unexpected Value Exception + * + * @link www.doctrine-project.com + * @since 2.4 + * @author Marco Pivetta + */ +class UnexpectedValueException extends BaseUnexpectedValueException implements ProxyException +{ + /** + * @return self + */ + public static function proxyDirectoryNotWritable() + { + return new self('Your proxy directory must be writable'); + } + + /** + * @param string $className + * @param string $methodName + * @param string $parameterName + * + * @return self + */ + public static function invalidParameterTypeHint($className, $methodName, $parameterName, \Exception $previous) + { + return new self( + sprintf( + 'The type hint of parameter "%s" in method "%s" in class "%s" is invalid.', + $parameterName, + $methodName, + $className + ), + 0, + $previous + ); + } +} diff --git a/lib/Doctrine/Common/Proxy/Proxy.php b/lib/Doctrine/Common/Proxy/Proxy.php new file mode 100644 index 000000000..b654951f9 --- /dev/null +++ b/lib/Doctrine/Common/Proxy/Proxy.php @@ -0,0 +1,83 @@ +. + */ + +namespace Doctrine\Common\Proxy; + +use Doctrine\Common\Persistence\Proxy as BaseProxy; +use Closure; + +/** + * Interface for proxy classes. + * + * @author Roman Borschel + * @author Marco Pivetta + * @since 2.4 + */ +interface Proxy extends BaseProxy +{ + /** + * Marks the proxy as initialized or not. + * + * @param boolean $initialized + */ + public function __setInitialized($initialized); + + /** + * Set the initializer callback to be used when initializing the proxy. That + * initializer should accept 3 parameters: $proxy, $method and $params. Those + * are respectively the proxy object that is being initialized, the method name + * that triggered initialization and the parameters passed to that method + * + * @param Closure $initializer + */ + public function __setInitializer(Closure $initializer = null); + + /** + * Retrieves the initializer callback used to initialize the proxy. + * @see __setInitializer + * + * @return Closure|null + */ + public function __getInitializer(); + + /** + * Set the callback to be used when cloning the proxy. That initializer should accept + * a single parameter, which is the cloned proxy instance itself + * + * @param Closure $cloner + */ + public function __setCloner(Closure $cloner = null); + + /** + * Retrieves the callback to be used when cloning the proxy. + * @see __setCloner + * + * @return Closure|null + */ + public function __getCloner(); + + /** + * Retrieves the list of lazy loaded properties for a given proxy + * + * @return array with keys being the property names, and values being the default values + * for those properties + */ + public function __getLazyProperties(); +} diff --git a/lib/Doctrine/Common/Proxy/ProxyGenerator.php b/lib/Doctrine/Common/Proxy/ProxyGenerator.php new file mode 100644 index 000000000..305c281c4 --- /dev/null +++ b/lib/Doctrine/Common/Proxy/ProxyGenerator.php @@ -0,0 +1,921 @@ +. + */ + +namespace Doctrine\Common\Proxy; + +use Doctrine\Common\Persistence\Mapping\ClassMetadata; +use Doctrine\Common\Util\ClassUtils; +use Doctrine\Common\Proxy\Exception\InvalidArgumentException; +use Doctrine\Common\Proxy\Exception\UnexpectedValueException; + +/** + * This factory is used to generate proxy classes. It builds proxies from given parameters, a template and class + * metadata. + * + * @author Marco Pivetta + * @since 2.4 + */ +class ProxyGenerator +{ + /** + * Used to match very simple id methods that don't need + * to be decorated since the identifier is known. + */ + const PATTERN_MATCH_ID_METHOD = '((public\s)?(function\s{1,}%s\s?\(\)\s{1,})\s{0,}{\s{0,}return\s{0,}\$this->%s;\s{0,}})i'; + + /** + * @var string The namespace that contains all proxy classes. + */ + private $proxyNamespace; + + /** + * @var string The directory that contains all proxy classes. + */ + private $proxyDirectory; + + /** + * @var string[]|callable[] map of callables used to fill in placeholders set in the template + */ + protected $placeholders = array(); + + /** + * @var string template used as a blueprint to generate proxies + */ + protected $proxyClassTemplate = '; + +/** + * DO NOT EDIT THIS FILE - IT WAS CREATED BY DOCTRINE\'S PROXY GENERATOR + */ +class extends \ implements \ +{ + /** + * @var \Closure the callback responsible for loading properties in the proxy object. This callback is called with + * three parameters, being respectively the proxy object to be initialized, the method that triggered the + * initialization process and an array of ordered parameters that were passed to that method. + * + * @see \Doctrine\Common\Persistence\Proxy::__setInitializer + */ + public $__initializer__; + + /** + * @var \Closure the callback responsible of loading properties that need to be copied in the cloned object + * + * @see \Doctrine\Common\Persistence\Proxy::__setCloner + */ + public $__cloner__; + + /** + * @var boolean flag indicating if this object was already initialized + * + * @see \Doctrine\Common\Persistence\Proxy::__isInitialized + */ + public $__isInitialized__ = false; + + /** + * @var array properties to be lazy loaded, with keys being the property + * names and values being their default values + * + * @see \Doctrine\Common\Persistence\Proxy::__getLazyProperties + */ + public static $lazyPropertiesDefaults = array(); + + + + + + + + + + + + + + + + + + /** + * Forces initialization of the proxy + */ + public function __load() + { + $this->__initializer__ && $this->__initializer__->__invoke($this, \'__load\', array()); + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __isInitialized() + { + return $this->__isInitialized__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setInitialized($initialized) + { + $this->__isInitialized__ = $initialized; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setInitializer(\Closure $initializer = null) + { + $this->__initializer__ = $initializer; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __getInitializer() + { + return $this->__initializer__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setCloner(\Closure $cloner = null) + { + $this->__cloner__ = $cloner; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific cloning logic + */ + public function __getCloner() + { + return $this->__cloner__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + * @static + */ + public function __getLazyProperties() + { + return self::$lazyPropertiesDefaults; + } + + +} +'; + + /** + * Initializes a new instance of the ProxyFactory class that is + * connected to the given EntityManager. + * + * @param string $proxyDirectory The directory to use for the proxy classes. It must exist. + * @param string $proxyNamespace The namespace to use for the proxy classes. + * + * @throws InvalidArgumentException + */ + public function __construct($proxyDirectory, $proxyNamespace) + { + if ( ! $proxyDirectory) { + throw InvalidArgumentException::proxyDirectoryRequired(); + } + + if ( ! $proxyNamespace) { + throw InvalidArgumentException::proxyNamespaceRequired(); + } + + $this->proxyDirectory = $proxyDirectory; + $this->proxyNamespace = $proxyNamespace; + + $this->setPlaceholders(array( + '' => array($this, 'generateNamespace'), + '' => array($this, 'generateProxyShortClassName'), + '' => 'Doctrine\Common\Proxy\Proxy', + '' => array($this, 'generateClassName'), + '' => '', + '' => array($this, 'generateLazyPropertiesDefaults'), + '' => array($this, 'generateConstructorImpl'), + '' => array($this, 'generateMagicGet'), + '' => array($this, 'generateMagicSet'), + '' => array($this, 'generateMagicIsset'), + '' => array($this, 'generateSleepImpl'), + '' => array($this, 'generateWakeupImpl'), + '' => array($this, 'generateCloneImpl'), + '' => array($this, 'generateMethods'), + )); + } + + /** + * Set the placeholders to be replaced in the template + * + * @param string[]|callable[] $placeholders + */ + public function setPlaceholders(array $placeholders) + { + foreach ($placeholders as $name => $value) { + $this->setPlaceholder($name, $value); + } + } + + /** + * Set a placeholder to be replaced in the template + * + * @param string $name + * @param string|callable $placeholder + * + * @throws InvalidArgumentException + */ + public function setPlaceholder($name, $placeholder) + { + if ( ! is_string($placeholder) && ! is_callable($placeholder)) { + throw InvalidArgumentException::invalidPlaceholder($name); + } + + $this->placeholders[$name] = $placeholder; + } + + /** + * Set the base template used to create proxy classes + * + * @param string $proxyClassTemplate + */ + public function setProxyClassTemplate($proxyClassTemplate) + { + $this->proxyClassTemplate = (string) $proxyClassTemplate; + } + + /** + * Generate the Proxy class name + * + * @param string $originalClassName + * + * @return string the FQCN class name of the proxy. If the proxy does not exist, it is generated + */ + public function getProxyClassName($originalClassName) + { + return ClassUtils::generateProxyClassName($originalClassName, $this->proxyNamespace); + } + + /** + * Generates the proxy short class name to be used in the template + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateProxyShortClassName(ClassMetadata $class) + { + $proxyClassName = ClassUtils::generateProxyClassName($class->getName(), $this->proxyNamespace); + $parts = explode('\\', strrev($proxyClassName), 2); + + return strrev($parts[0]); + } + + /** + * Generates the proxy namespace + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateNamespace(ClassMetadata $class) + { + $proxyClassName = ClassUtils::generateProxyClassName($class->getName(), $this->proxyNamespace); + $parts = explode('\\', strrev($proxyClassName), 2); + + return strrev($parts[1]); + } + + /** + * Generates the original class name + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateClassName(ClassMetadata $class) + { + return ltrim($class->getName(), '\\'); + } + + /** + * Generates the array representation of lazy loaded public properties and their default values + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateLazyPropertiesDefaults(ClassMetadata $class) + { + $lazyPublicProperties = $this->getLazyLoadedPublicProperties($class); + $values = array(); + + foreach ($lazyPublicProperties as $key => $value) { + $values[] = var_export($key, true) . ' => ' . var_export($value, true); + } + + return implode(', ', $values); + } + + /** + * Generates the constructor code (un-setting public lazy loaded properties, setting identifier field values) + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateConstructorImpl(ClassMetadata $class) + { + $constructorImpl = <<<'EOT' + /** + * @param \Closure $initializer + * @param \Closure $cloner + */ + public function __construct($initializer = null, $cloner = null) + { + +EOT; + $toUnset = array(); + + foreach ($this->getLazyLoadedPublicProperties($class) as $lazyPublicProperty => $unused) { + $toUnset[] = '$this->' . $lazyPublicProperty; + } + + $constructorImpl .= (empty($toUnset) ? '' : ' unset(' . implode(', ', $toUnset) . ");\n") + . <<<'EOT' + + $this->__initializer__ = $initializer; + $this->__cloner__ = $cloner; + } +EOT; + + return $constructorImpl; + } + + /** + * Generates the magic getter invoked when lazy loaded public properties are requested + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateMagicGet(ClassMetadata $class) + { + $lazyPublicProperties = array_keys($this->getLazyLoadedPublicProperties($class)); + $hasParentGet = $class->getReflectionClass()->hasMethod('__get'); + + if (empty($lazyPublicProperties) && ! $hasParentGet) { + return ''; + } + + $inheritDoc = $hasParentGet ? '{@inheritDoc}' : ''; + $magicGet = <<__getLazyProperties())) { + $this->__initializer__ && $this->__initializer__->__invoke($this, '__get', array($name)); + + return $this->$name; + } + + +EOT; + } + + if ($hasParentGet) { + $magicGet .= <<<'EOT' + $this->__initializer__ && $this->__initializer__->__invoke($this, '__get', array($name)); + + return parent::__get($name) + +EOT; + } else { + $magicGet .= <<<'EOT' + trigger_error(sprintf('Undefined property: %s::$%s', __CLASS__, $name), E_USER_NOTICE); + +EOT; + } + + $magicGet .= " }"; + + return $magicGet; + } + + /** + * Generates the magic setter (currently unused) + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateMagicSet(ClassMetadata $class) + { + $lazyPublicProperties = $this->getLazyLoadedPublicProperties($class); + $hasParentSet = $class->getReflectionClass()->hasMethod('__set'); + + if (empty($lazyPublicProperties) && ! $hasParentSet) { + return ''; + } + + $inheritDoc = $hasParentSet ? '{@inheritDoc}' : ''; + $magicSet = <<__getLazyProperties())) { + $this->__initializer__ && $this->__initializer__->__invoke($this, '__set', array($name, $value)); + + $this->$name = $value; + + return; + } + + +EOT; + } + + if ($hasParentSet) { + $magicSet .= <<<'EOT' + $this->__initializer__ && $this->__initializer__->__invoke($this, '__set', array($name, $value)); + + return parent::__set(\$name, \$value) +EOT; + } else { + $magicSet .= " \$this->\$name = \$value;"; + } + + $magicSet .= "\n }"; + + return $magicSet; + } + + /** + * Generates the magic issetter invoked when lazy loaded public properties are checked against isset() + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateMagicIsset(ClassMetadata $class) + { + $lazyPublicProperties = array_keys($this->getLazyLoadedPublicProperties($class)); + $hasParentIsset = $class->getReflectionClass()->hasMethod('__isset'); + + if (empty($lazyPublicProperties) && ! $hasParentIsset) { + return ''; + } + + $inheritDoc = $hasParentIsset ? '{@inheritDoc}' : ''; + $magicIsset = <<__getLazyProperties())) { + $this->__initializer__ && $this->__initializer__->__invoke($this, '__isset', array($name)); + + return isset($this->$name); + } + + +EOT; + } + + if ($hasParentIsset) { + $magicIsset .= <<<'EOT' + $this->__initializer__ && $this->__initializer__->__invoke($this, '__isset', array($name)); + + return parent::__isset($name) + +EOT; + } else { + $magicIsset .= " return false;"; + } + + return $magicIsset . "\n }"; + } + + /** + * Generates implementation for the `__sleep` method of proxies. + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateSleepImpl(ClassMetadata $class) + { + $hasParentSleep = $class->getReflectionClass()->hasMethod('__sleep'); + $inheritDoc = $hasParentSleep ? '{@inheritDoc}' : ''; + $sleepImpl = <<__isInitialized__) { + $properties = array_diff($properties, array_keys($this->__getLazyProperties())); + } + + return $properties; + } +EOT; + } + + $allProperties = array('__isInitialized__'); + + /* @var $prop \ReflectionProperty */ + foreach ($class->getReflectionClass()->getProperties() as $prop) { + $allProperties[] = $prop->getName(); + } + + $lazyPublicProperties = array_keys($this->getLazyLoadedPublicProperties($class)); + $protectedProperties = array_diff($allProperties, $lazyPublicProperties); + + foreach ($allProperties as &$property) { + $property = var_export($property, true); + } + + foreach ($protectedProperties as &$property) { + $property = var_export($property, true); + } + + $allProperties = implode(', ', $allProperties); + $protectedProperties = implode(', ', $protectedProperties); + + return $sleepImpl . <<__isInitialized__) { + return array($allProperties); + } + + return array($protectedProperties); + } +EOT; + } + + /** + * Generates implementation for the `__wakeup` method of proxies. + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateWakeupImpl(ClassMetadata $class) + { + $unsetPublicProperties = array(); + $hasWakeup = $class->getReflectionClass()->hasMethod('__wakeup'); + + foreach (array_keys($this->getLazyLoadedPublicProperties($class)) as $lazyPublicProperty) { + $unsetPublicProperties[] = '$this->' . $lazyPublicProperty; + } + + $shortName = $this->generateProxyShortClassName($class); + $inheritDoc = $hasWakeup ? '{@inheritDoc}' : ''; + $wakeupImpl = <<__isInitialized__) { + \$this->__initializer__ = function ($shortName \$proxy) { + \$proxy->__setInitializer(null); + \$proxy->__setCloner(null); + + \$existingProperties = get_object_vars(\$proxy); + + foreach (\$proxy->__getLazyProperties() as \$property => \$defaultValue) { + if ( ! array_key_exists(\$property, \$existingProperties)) { + \$proxy->\$property = \$defaultValue; + } + } + }; + +EOT; + + if ( ! empty($unsetPublicProperties)) { + $wakeupImpl .= "\n unset(" . implode(', ', $unsetPublicProperties) . ");"; + } + + $wakeupImpl .= "\n }"; + + if ($hasWakeup) { + $wakeupImpl .= "\n parent::__wakeup();"; + } + + $wakeupImpl .= "\n }"; + + return $wakeupImpl; + } + + /** + * Generates implementation for the `__clone` method of proxies. + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateCloneImpl(ClassMetadata $class) + { + $hasParentClone = $class->getReflectionClass()->hasMethod('__clone'); + $inheritDoc = $hasParentClone ? '{@inheritDoc}' : ''; + $callParentClone = $hasParentClone ? "\n parent::__clone();" : ''; + + return <<__cloner__ && \$this->__cloner__->__invoke(\$this, '__clone', array()); +$callParentClone } +EOT; + } + + /** + * Generates decorated methods by picking those available in the parent class + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return string + */ + public function generateMethods(ClassMetadata $class) + { + $methods = ''; + $methodNames = array(); + $reflectionMethods = $class->getReflectionClass()->getMethods(\ReflectionMethod::IS_PUBLIC); + $skippedMethods = array( + '__sleep' => true, + '__clone' => true, + '__wakeup' => true, + '__get' => true, + '__set' => true, + '__isset' => true, + ); + + foreach ($reflectionMethods as $method) { + $name = $method->getName(); + + if ( + $method->isConstructor() || + isset($skippedMethods[strtolower($name)]) || + isset($methodNames[$name]) || + $method->isFinal() || + $method->isStatic() || + ( ! $method->isPublic()) + ) { + continue; + } + + $methodNames[$name] = true; + $methods .= "\n /**\n" + . " * {@inheritDoc}\n" + . " */\n" + . ' public function '; + + if ($method->returnsReference()) { + $methods .= '&'; + } + + $methods .= $name . '('; + + $firstParam = true; + $parameterString = ''; + $argumentString = ''; + $parameters = array(); + + foreach ($method->getParameters() as $param) { + if ($firstParam) { + $firstParam = false; + } else { + $parameterString .= ', '; + $argumentString .= ', '; + } + + try { + $paramClass = $param->getClass(); + } catch (\ReflectionException $previous) { + throw UnexpectedValueException::invalidParameterTypeHint( + $class->getName(), + $method->getName(), + $param->getName(), + $previous + ); + } + + // We need to pick the type hint class too + if (null !== $paramClass) { + $parameterString .= '\\' . $paramClass->getName() . ' '; + } elseif ($param->isArray()) { + $parameterString .= 'array '; + } + + if ($param->isPassedByReference()) { + $parameterString .= '&'; + } + + $parameters[] = '$' . $param->getName(); + $parameterString .= '$' . $param->getName(); + $argumentString .= '$' . $param->getName(); + + if ($param->isDefaultValueAvailable()) { + $parameterString .= ' = ' . var_export($param->getDefaultValue(), true); + } + } + + $methods .= $parameterString . ')'; + $methods .= "\n" . ' {' . "\n"; + + if ($this->isShortIdentifierGetter($method, $class)) { + $identifier = lcfirst(substr($name, 3)); + $fieldType = $class->getTypeOfField($identifier); + $cast = in_array($fieldType, array('integer', 'smallint')) ? '(int) ' : ''; + + $methods .= ' if ($this->__isInitialized__ === false) {' . "\n"; + $methods .= ' return ' . $cast . ' parent::' . $method->getName() . "();\n"; + $methods .= ' }' . "\n\n"; + } + + $methods .= "\n \$this->__initializer__ " + . "&& \$this->__initializer__->__invoke(\$this, " . var_export($name, true) + . ", array(" . implode(', ', $parameters) . "));" + . "\n\n return parent::" . $name . '(' . $argumentString . ');' + . "\n" . ' }' . "\n"; + } + + return $methods; + } + + /** + * Generate the Proxy file name + * + * @param string $className + * @param string $baseDirectory Optional base directory for proxy file name generation. + * If not specified, the directory configured on the Configuration of the + * EntityManager will be used by this factory. + * + * @return string + */ + public function getProxyFileName($className, $baseDirectory = null) + { + $baseDirectory = $baseDirectory ?: $this->proxyDirectory; + + return rtrim($baseDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . Proxy::MARKER + . str_replace('\\', '', $className) . '.php'; + } + + /** + * Generates a proxy class file. + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class Metadata for the original class + * @param string $fileName Filename (full path) for the generated class + * @param string $file The proxy class template data + * + * @throws UnexpectedValueException + */ + public function generateProxyClass(ClassMetadata $class, $fileName = null, $file = null) + { + $placeholders = array(); + + foreach ($this->placeholders as $name => $placeholder) { + $placeholders[$name] = is_callable($placeholder) ? call_user_func($placeholder, $class) : $placeholder; + } + + $fileName = $fileName ?: $this->getProxyFileName($class->getName()); + $file = $file ? $file : $this->proxyClassTemplate; + $file = strtr($file, $placeholders); + + $parentDirectory = dirname($fileName); + + if ( ! is_dir($parentDirectory) && (false === @mkdir($parentDirectory, 0775, true))) { + throw UnexpectedValueException::proxyDirectoryNotWritable(); + } elseif ( ! is_writable($parentDirectory)) { + throw UnexpectedValueException::proxyDirectoryNotWritable(); + } + + $tmpFileName = $fileName . '.' . uniqid('', true); + file_put_contents($tmpFileName, $file); + rename($tmpFileName, $fileName); + } + + /** + * Check if the method is a short identifier getter. + * + * What does this mean? For proxy objects the identifier is already known, + * however accessing the getter for this identifier usually triggers the + * lazy loading, leading to a query that may not be necessary if only the + * ID is interesting for the userland code (for example in views that + * generate links to the entity, but do not display anything else). + * + * @param \ReflectionMethod $method + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return boolean + */ + private function isShortIdentifierGetter($method, ClassMetadata $class) + { + $identifier = lcfirst(substr($method->getName(), 3)); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $cheapCheck = ( + $method->getNumberOfParameters() == 0 + && substr($method->getName(), 0, 3) == 'get' + && in_array($identifier, $class->getIdentifier(), true) + && $class->hasField($identifier) + && (($endLine - $startLine) <= 4) + ); + + if ($cheapCheck) { + $code = file($method->getDeclaringClass()->getFileName()); + $code = trim(implode(' ', array_slice($code, $startLine - 1, $endLine - $startLine + 1))); + + $pattern = sprintf(self::PATTERN_MATCH_ID_METHOD, $method->getName(), $identifier); + + if (preg_match($pattern, $code)) { + return true; + } + } + + return false; + } + + /** + * Generates the list of public properties to be lazy loaded, with their default values + * + * @param \Doctrine\Common\Persistence\Mapping\ClassMetadata $class + * + * @return mixed[] + */ + private function getLazyLoadedPublicProperties(ClassMetadata $class) + { + $defaultProperties = $class->getReflectionClass()->getDefaultProperties(); + $properties = array(); + + foreach ($class->getReflectionClass()->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + $name = $property->getName(); + + if (($class->hasField($name) || $class->hasAssociation($name)) && ! $class->isIdentifier($name)) { + $properties[$name] = $defaultProperties[$name]; + } + } + + return $properties; + } +} diff --git a/lib/Doctrine/Common/Reflection/RuntimePublicReflectionProperty.php b/lib/Doctrine/Common/Reflection/RuntimePublicReflectionProperty.php new file mode 100644 index 000000000..3211917cb --- /dev/null +++ b/lib/Doctrine/Common/Reflection/RuntimePublicReflectionProperty.php @@ -0,0 +1,76 @@ +. + */ + +namespace Doctrine\Common\Reflection; + +use ReflectionProperty; +use Doctrine\Common\Proxy\Proxy; + +/** + * PHP Runtime Reflection Public Property - special overrides for public properties + * + * @author Marco Pivetta + * @since 2.4 + */ +class RuntimePublicReflectionProperty extends ReflectionProperty +{ + /** + * {@inheritDoc} + * + * Checks is the value actually exist before fetching it. + * This is to avoid calling `__get` on the provided $object if it + * is a {@see \Doctrine\Common\Proxy\Proxy}. + */ + public function getValue($object = null) + { + $name = $this->getName(); + + if ($object instanceof Proxy && ! $object->__isInitialized()) { + $originalInitializer = $object->__getInitializer(); + $object->__setInitializer(null); + $val = isset($object->$name) ? $object->$name : null; + $object->__setInitializer($originalInitializer); + + return $val; + } + + return isset($object->$name) ? parent::getValue($object) : null; + } + + /** + * {@inheritDoc} + * + * Avoids triggering lazy loading via `__set` if the provided object + * is a {@see \Doctrine\Common\Proxy\Proxy}. + * @link https://bugs.php.net/bug.php?id=63463 + */ + public function setValue($object, $value = null) + { + if ( ! ($object instanceof Proxy && ! $object->__isInitialized())) { + parent::setValue($object, $value); + + return; + } + + $originalInitializer = $object->__getInitializer(); + $object->__setInitializer(null); + parent::setValue($object, $value); + $object->__setInitializer($originalInitializer); + } +} diff --git a/tests/Doctrine/Tests/Common/Persistence/Mapping/RuntimeReflectionServiceTest.php b/tests/Doctrine/Tests/Common/Persistence/Mapping/RuntimeReflectionServiceTest.php index 5f06cad98..a5d6199e4 100644 --- a/tests/Doctrine/Tests/Common/Persistence/Mapping/RuntimeReflectionServiceTest.php +++ b/tests/Doctrine/Tests/Common/Persistence/Mapping/RuntimeReflectionServiceTest.php @@ -26,8 +26,13 @@ */ class RuntimeReflectionServiceTest extends \PHPUnit_Framework_TestCase { + /** + * @var RuntimeReflectionService + */ private $reflectionService; + public $unusedPublicProperty; + public function setUp() { $this->reflectionService = new RuntimeReflectionService(); @@ -65,6 +70,9 @@ public function testGetAccessibleProperty() { $reflProp = $this->reflectionService->getAccessibleProperty(__CLASS__, "reflectionService"); $this->assertInstanceOf("ReflectionProperty", $reflProp); + + $reflProp = $this->reflectionService->getAccessibleProperty(__CLASS__, "unusedPublicProperty"); + $this->assertInstanceOf("Doctrine\Common\Reflection\RuntimePublicReflectionProperty", $reflProp); } } diff --git a/tests/Doctrine/Tests/Common/Proxy/AutoloaderTest.php b/tests/Doctrine/Tests/Common/Proxy/AutoloaderTest.php new file mode 100644 index 000000000..c15baffa9 --- /dev/null +++ b/tests/Doctrine/Tests/Common/Proxy/AutoloaderTest.php @@ -0,0 +1,72 @@ +. + */ + +namespace Doctrine\Tests\Common\Proxy; + +use PHPUnit_Framework_TestCase; +use Doctrine\Common\Proxy\Autoloader; + +/** + * @group DDC-1698 + */ +class AutoloaderTest extends PHPUnit_Framework_TestCase +{ + public static function dataResolveFile() + { + return array( + array('/tmp', 'MyProxy', 'MyProxy\__CG__\RealClass', '/tmp' . DIRECTORY_SEPARATOR . '__CG__RealClass.php'), + array('/tmp', 'MyProxy\Subdir', 'MyProxy\Subdir\__CG__\RealClass', '/tmp' . DIRECTORY_SEPARATOR . '__CG__RealClass.php'), + array('/tmp', 'MyProxy', 'MyProxy\__CG__\Other\RealClass', '/tmp' . DIRECTORY_SEPARATOR . '__CG__OtherRealClass.php'), + ); + } + + /** + * @dataProvider dataResolveFile + */ + public function testResolveFile($proxyDir, $proxyNamespace, $className, $expectedProxyFile) + { + $actualProxyFile = Autoloader::resolveFile($proxyDir, $proxyNamespace, $className); + $this->assertEquals($expectedProxyFile, $actualProxyFile); + } + + public function testAutoload() + { + if (file_exists(sys_get_temp_dir() ."/AutoloaderTestClass.php")) { + unlink(sys_get_temp_dir() ."/AutoloaderTestClass.php"); + } + + $autoloader = Autoloader::register(sys_get_temp_dir(), 'ProxyAutoloaderTest', function($proxyDir, $proxyNamespace, $className) { + file_put_contents(sys_get_temp_dir() . "/AutoloaderTestClass.php", "assertTrue(class_exists('ProxyAutoloaderTest\AutoloaderTestClass', true)); + unlink(sys_get_temp_dir() ."/AutoloaderTestClass.php"); + } + + public function testRegisterWithInvalidCallback() + { + $this->setExpectedException( + 'Doctrine\Common\Proxy\Exception\InvalidArgumentException', + 'Invalid \$notFoundCallback given: must be a callable, "stdClass" given' + ); + + Autoloader::register('', '', new \stdClass()); + } +} + diff --git a/tests/Doctrine/Tests/Common/Proxy/InvalidTypeHintClass.php b/tests/Doctrine/Tests/Common/Proxy/InvalidTypeHintClass.php new file mode 100644 index 000000000..bed55d2df --- /dev/null +++ b/tests/Doctrine/Tests/Common/Proxy/InvalidTypeHintClass.php @@ -0,0 +1,16 @@ +. + */ + +namespace Doctrine\Tests\Common\Proxy; + +/** + * Test asset representing a lazy loadable object + * + * @author Marco Pivetta + * @since 2.4 + */ +class LazyLoadableObject +{ + /** + * @var string + */ + public $publicIdentifierField; + + /** + * @var string + */ + protected $protectedIdentifierField; + + /** + * @var string + */ + public $publicTransientField = 'publicTransientFieldValue'; + + /** + * @var string + */ + protected $protectedTransientField = 'protectedTransientFieldValue'; + + /** + * @var string + */ + public $publicPersistentField = 'publicPersistentFieldValue'; + + /** + * @var string + */ + protected $protectedPersistentField = 'protectedPersistentFieldValue'; + + /** + * @var string + */ + public $publicAssociation = 'publicAssociationValue'; + + /** + * @var string + */ + protected $protectedAssociation = 'protectedAssociationValue'; + + /** + * @return string + */ + public function getProtectedIdentifierField() + { + return $this->protectedIdentifierField; + } + + /** + * @return string + */ + public function testInitializationTriggeringMethod() + { + return 'testInitializationTriggeringMethod'; + } + + /** + * @return string + */ + public function getProtectedAssociation() + { + return $this->protectedAssociation; + } + + /** + * @param \stdClass $param + */ + public function publicTypeHintedMethod(\stdClass $param) + { + } + + /** + * + */ + public function &byRefMethod() + { + } + + /** + * @param mixed $thisIsNotByRef + * @param &mixed $thisIsByRef + */ + public function byRefParamMethod($thisIsNotByRef, &$thisIsByRef) + { + } +} diff --git a/tests/Doctrine/Tests/Common/Proxy/LazyLoadableObjectClassMetadata.php b/tests/Doctrine/Tests/Common/Proxy/LazyLoadableObjectClassMetadata.php new file mode 100644 index 000000000..167386e35 --- /dev/null +++ b/tests/Doctrine/Tests/Common/Proxy/LazyLoadableObjectClassMetadata.php @@ -0,0 +1,195 @@ +. + */ + +namespace Doctrine\Tests\Common\Proxy; + +use ReflectionClass; +use Doctrine\Common\Persistence\Mapping\ClassMetadata; + +/** + * Class metadata test asset for @see LazyLoadableObject + * + * @author Marco Pivetta + * @since 2.4 + */ +class LazyLoadableObjectClassMetadata implements ClassMetadata +{ + /** + * @var ReflectionClass + */ + protected $reflectionClass; + + /** + * @var array + */ + protected $identifier = array( + 'publicIdentifierField' => true, + 'protectedIdentifierField' => true, + ); + + /** + * @var array + */ + protected $fields = array( + 'publicIdentifierField' => true, + 'protectedIdentifierField' => true, + 'publicPersistentField' => true, + 'protectedPersistentField' => true, + ); + + /** + * @var array + */ + protected $associations = array( + '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 (null === $this->reflectionClass) { + $this->reflectionClass = new \ReflectionClass(__NAMESPACE__ . '\LazyLoadableObject'); + } + + 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/Doctrine/Tests/Common/Proxy/ProxyClassGeneratorTest.php b/tests/Doctrine/Tests/Common/Proxy/ProxyClassGeneratorTest.php new file mode 100644 index 000000000..6d2a6f322 --- /dev/null +++ b/tests/Doctrine/Tests/Common/Proxy/ProxyClassGeneratorTest.php @@ -0,0 +1,161 @@ +. + */ + + +namespace Doctrine\Tests\Common\Proxy; + +use Doctrine\Common\Proxy\ProxyGenerator; +use ReflectionClass; +use ReflectionMethod; +use PHPUnit_Framework_TestCase; + +/** + * Test the proxy generator. Its work is generating on-the-fly subclasses of a given model, which implement the Proxy + * pattern. + * + * @author Giorgio Sironi + * @author Marco Pivetta + */ +class ProxyClassGeneratorTest extends PHPUnit_Framework_TestCase +{ + /** + * @var string + */ + protected $proxyClass = 'Doctrine\Tests\Common\ProxyProxy\__CG__\Doctrine\Tests\Common\Proxy\LazyLoadableObject'; + + /** + * @var LazyLoadableObjectClassMetadata + */ + protected $metadata; + + /** + * @var ProxyGenerator + */ + protected $proxyGenerator; + + /** + * {@inheritDoc} + */ + protected function setUp() + { + $this->metadata = new LazyLoadableObjectClassMetadata(); + $this->proxyGenerator = new ProxyGenerator(__DIR__ . '/generated', __NAMESPACE__ . 'Proxy', true); + + if (class_exists($this->proxyClass, false)) { + return; + } + + $this->proxyGenerator->generateProxyClass($this->metadata); + require_once $this->proxyGenerator->getProxyFileName($this->metadata->getName()); + } + + public function testReferenceProxyRespectsMethodsParametersTypeHinting() + { + $method = new ReflectionMethod($this->proxyClass, 'publicTypeHintedMethod'); + $params = $method->getParameters(); + + $this->assertEquals(1, count($params)); + $this->assertEquals('stdClass', $params[0]->getClass()->getName()); + } + + public function testProxyRespectsMethodsWhichReturnValuesByReference() + { + $method = new ReflectionMethod($this->proxyClass, 'byRefMethod'); + + $this->assertTrue($method->returnsReference()); + } + + public function testProxyRespectsByRefMethodParameters() + { + $method = new ReflectionMethod($this->proxyClass, 'byRefParamMethod'); + $parameters = $method->getParameters(); + $this->assertSame('thisIsNotByRef', $parameters[0]->getName()); + $this->assertFalse($parameters[0]->isPassedByReference()); + $this->assertSame('thisIsByRef', $parameters[1]->getName()); + $this->assertTrue($parameters[1]->isPassedByReference()); + } + + public function testCreatesAssociationProxyAsSubclassOfTheOriginalOne() + { + $this->assertTrue(is_subclass_of($this->proxyClass, $this->metadata->getName())); + } + + public function testNonNamespacedProxyGeneration() + { + $classCode = file_get_contents($this->proxyGenerator->getProxyFileName($this->metadata->getName())); + + $this->assertNotContains("class LazyLoadableObject extends \\\\" . $this->metadata->getName(), $classCode); + $this->assertContains("class LazyLoadableObject extends \\" . $this->metadata->getName(), $classCode); + } + + public function testClassWithSleepProxyGeneration() + { + if (!class_exists('Doctrine\Tests\Common\ProxyProxy\__CG__\SleepClass', false)) { + $metadata = $this->getMock('Doctrine\Common\Persistence\Mapping\ClassMetadata'); + $reflClass = new ReflectionClass('Doctrine\Tests\Common\Proxy\SleepClass'); + $metadata->expects($this->any())->method('getReflectionClass')->will($this->returnValue($reflClass)); + $metadata->expects($this->any())->method('getIdentifierFieldNames')->will($this->returnValue(array('id'))); + $metadata->expects($this->any())->method('getName')->will($this->returnValue($reflClass->getName())); + $proxyGenerator = new ProxyGenerator(__DIR__ . '/generated', __NAMESPACE__ . 'Proxy', true); + $proxyGenerator->generateProxyClass($metadata); + require_once $proxyGenerator->getProxyFileName($metadata->getName()); + } + + $classCode = file_get_contents(__DIR__ . '/generated/__CG__DoctrineTestsCommonProxySleepClass.php'); + $this->assertEquals(1, substr_count($classCode, 'function __sleep')); + $this->assertEquals(1, substr_count($classCode, 'parent::__sleep()')); + } + + public function testClassWithInvalidTypeHintOnProxiedMethod() + { + $className = 'Doctrine\Tests\Common\Proxy\InvalidTypeHintClass'; + $metadata = $this->getMock('Doctrine\Common\Persistence\Mapping\ClassMetadata'); + $reflClass = new ReflectionClass($className); + $metadata->expects($this->any())->method('getReflectionClass')->will($this->returnValue($reflClass)); + $metadata->expects($this->any())->method('getIdentifierFieldNames')->will($this->returnValue(array())); + $metadata->expects($this->any())->method('getName')->will($this->returnValue($className)); + $proxyGenerator = new ProxyGenerator(__DIR__ . '/generated', __NAMESPACE__ . 'Proxy', true); + + $this->setExpectedException( + 'Doctrine\Common\Proxy\Exception\UnexpectedValueException', + 'The type hint of parameter "foo" in method "invalidTypeHintMethod"' + .' in class "' . $className . '" is invalid.' + ); + $proxyGenerator->generateProxyClass($metadata); + } + + public function testNoConfigDirThrowsException() + { + $this->setExpectedException('Doctrine\Common\Proxy\Exception\InvalidArgumentException'); + new ProxyGenerator(null, null); + } + + public function testNoNamespaceThrowsException() + { + $this->setExpectedException('Doctrine\Common\Proxy\Exception\InvalidArgumentException'); + new ProxyGenerator(__DIR__ . '/generated', null); + } + + public function testInvalidPlaceholderThrowsException() + { + $this->setExpectedException('Doctrine\Common\Proxy\Exception\InvalidArgumentException'); + $generator = new ProxyGenerator(__DIR__ . '/generated', 'SomeNamespace'); + $generator->setPlaceholder('', array()); + } +} diff --git a/tests/Doctrine/Tests/Common/Proxy/ProxyLogicTest.php b/tests/Doctrine/Tests/Common/Proxy/ProxyLogicTest.php new file mode 100644 index 000000000..9a5173c1d --- /dev/null +++ b/tests/Doctrine/Tests/Common/Proxy/ProxyLogicTest.php @@ -0,0 +1,696 @@ +. + */ + +namespace Doctrine\Tests\Common\Proxy; + +use Doctrine\Common\Proxy\ProxyGenerator; +use Doctrine\Common\Proxy\Proxy; +use Doctrine\Common\Proxy\Exception\UnexpectedValueException; +use Doctrine\Common\Persistence\Mapping\ClassMetadata; +use PHPUnit_Framework_TestCase; + +/** + * Test the generated proxies behavior. These tests make assumptions about the structure of LazyLoadableObject + * + * @author Marco Pivetta + */ +class ProxyLogicTest extends PHPUnit_Framework_TestCase +{ + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + protected $proxyLoader; + + /** + * @var ClassMetadata + */ + protected $lazyLoadableObjectMetadata; + + /** + * @var LazyLoadableObject|Proxy + */ + protected $lazyObject; + + protected $identifier = array( + 'publicIdentifierField' => 'publicIdentifierFieldValue', + 'protectedIdentifierField' => 'protectedIdentifierFieldValue', + ); + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|Callable + */ + protected $initializerCallbackMock; + + /** + * {@inheritDoc} + */ + public function setUp() + { + $this->proxyLoader = $loader = $this->getMock('stdClass', array('load'), array(), '', false); + $this->initializerCallbackMock = $this->getMock('stdClass', array('__invoke')); + $identifier = $this->identifier; + $this->lazyLoadableObjectMetadata = $metadata = new LazyLoadableObjectClassMetadata(); + + // emulating what should happen in a proxy factory + $cloner = function (LazyLoadableObject $proxy) use ($loader, $identifier, $metadata) { + /* @var $proxy LazyLoadableObject|Proxy */ + if ($proxy->__isInitialized()) { + return; + } + + $proxy->__setInitialized(true); + $proxy->__setInitializer(null); + $original = $loader->load($identifier); + + if (null === $original) { + throw new UnexpectedValueException(); + } + + foreach ($metadata->getReflectionClass()->getProperties() as $reflProperty) { + $propertyName = $reflProperty->getName(); + + if ($metadata->hasField($propertyName) || $metadata->hasAssociation($propertyName)) { + $reflProperty->setAccessible(true); + $reflProperty->setValue($proxy, $reflProperty->getValue($original)); + } + } + }; + + $proxyClassName = 'Doctrine\Tests\Common\ProxyProxy\__CG__\Doctrine\Tests\Common\Proxy\LazyLoadableObject'; + + // creating the proxy class + if (!class_exists($proxyClassName, false)) { + $proxyGenerator = new ProxyGenerator(__DIR__ . '/generated', __NAMESPACE__ . 'Proxy', true); + $proxyGenerator->generateProxyClass($metadata); + require_once $proxyGenerator->getProxyFileName($metadata->getName()); + } + + $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]); + } + + $this->assertFalse($this->lazyObject->__isInitialized()); + } + + public function testFetchingPublicIdentifierDoesNotCauseLazyLoading() + { + $this->configureInitializerMock(0); + + $this->assertSame('publicIdentifierFieldValue', $this->lazyObject->publicIdentifierField); + } + + public function testFetchingIdentifiersViaPublicGetterDoesNotCauseLazyLoading() + { + $this->configureInitializerMock(0); + + $this->assertSame('protectedIdentifierFieldValue', $this->lazyObject->getProtectedIdentifierField()); + } + + public function testCallingMethodCausesLazyLoading() + { + $this->configureInitializerMock( + 1, + array($this->lazyObject, 'testInitializationTriggeringMethod', array()), + function (Proxy $proxy) { + $proxy->__setInitializer(null); + } + ); + + $this->lazyObject->testInitializationTriggeringMethod(); + $this->lazyObject->testInitializationTriggeringMethod(); + } + + public function testFetchingPublicFieldsCausesLazyLoading() + { + $test = $this; + $this->configureInitializerMock( + 1, + array($this->lazyObject, '__get', array('publicPersistentField')), + function () use ($test) { + $test->setProxyValue('publicPersistentField', 'loadedValue'); + } + ); + + $this->assertSame('loadedValue', $this->lazyObject->publicPersistentField); + $this->assertSame('loadedValue', $this->lazyObject->publicPersistentField); + } + + public function testFetchingPublicAssociationCausesLazyLoading() + { + $test = $this; + $this->configureInitializerMock( + 1, + array($this->lazyObject, '__get', array('publicAssociation')), + function () use ($test) { + $test->setProxyValue('publicAssociation', 'loadedAssociation'); + } + ); + + $this->assertSame('loadedAssociation', $this->lazyObject->publicAssociation); + $this->assertSame('loadedAssociation', $this->lazyObject->publicAssociation); + } + + public function testFetchingProtectedAssociationViaPublicGetterCausesLazyLoading() + { + $this->configureInitializerMock( + 1, + array($this->lazyObject, 'getProtectedAssociation', array()), + function (Proxy $proxy) { + $proxy->__setInitializer(null); + } + ); + + $this->assertSame('protectedAssociationValue', $this->lazyObject->getProtectedAssociation()); + $this->assertSame('protectedAssociationValue', $this->lazyObject->getProtectedAssociation()); + } + + public function testLazyLoadingTriggeredOnlyAtFirstPublicPropertyRead() + { + $test = $this; + $this->configureInitializerMock( + 1, + array($this->lazyObject, '__get', array('publicPersistentField')), + function () use ($test) { + $test->setProxyValue('publicPersistentField', 'loadedValue'); + $test->setProxyValue('publicAssociation', 'publicAssociationValue'); + } + ); + + $this->assertSame('loadedValue', $this->lazyObject->publicPersistentField); + $this->assertSame('publicAssociationValue', $this->lazyObject->publicAssociation); + } + + public function testNoticeWhenReadingNonExistentPublicProperties() + { + $this->configureInitializerMock(0); + + $class = get_class($this->lazyObject); + $this->setExpectedException( + 'PHPUnit_Framework_Error_Notice', + 'Undefined property: ' . $class . '::$non_existing_property' + ); + + $this->lazyObject->non_existing_property; + } + + public function testFalseWhenCheckingNonExistentProperty() + { + $this->configureInitializerMock(0); + + $this->assertFalse(isset($this->lazyObject->non_existing_property)); + } + + public function testNoErrorWhenSettingNonExistentProperty() + { + $this->configureInitializerMock(0); + + $this->lazyObject->non_existing_property = 'now has a value'; + $this->assertSame('now has a value', $this->lazyObject->non_existing_property); + } + + public function testCloningCallsClonerWithClonedObject() + { + $lazyObject = $this->lazyObject; + $test = $this; + $cb = $this->getMock('stdClass', array('cb')); + $cb + ->expects($this->once()) + ->method('cb') + ->will($this->returnCallback(function (LazyLoadableObject $proxy) use ($lazyObject, $test) { + /* @var $proxy LazyLoadableObject|Proxy */ + $test->assertNotSame($proxy, $lazyObject); + $proxy->__setInitializer(null); + $proxy->publicAssociation = 'clonedAssociation'; + })); + + $this->lazyObject->__setCloner($this->getClosure(array($cb, 'cb'))); + + $cloned = clone $this->lazyObject; + $this->assertSame('clonedAssociation', $cloned->publicAssociation); + $this->assertNotSame($cloned, $lazyObject, 'a clone of the lazy object is retrieved'); + } + + public function testFetchingTransientPropertiesWillNotTriggerLazyLoading() + { + $this->configureInitializerMock(0); + + $this->assertSame( + 'publicTransientFieldValue', + $this->lazyObject->publicTransientField, + 'fetching public transient field won\'t trigger lazy loading' + ); + $property = $this + ->lazyLoadableObjectMetadata + ->getReflectionClass() + ->getProperty('protectedTransientField'); + $property->setAccessible(true); + $this->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, array($this->lazyObject, '__load', array())); + + $this->lazyObject->__load(); + $this->lazyObject->__load(); + } + + public function testLoadingWithPersisterWillBeTriggeredOnlyOnce() + { + $this + ->proxyLoader + ->expects($this->once()) + ->method('load') + ->with( + array( + 'publicIdentifierField' => 'publicIdentifierFieldValue', + 'protectedIdentifierField' => 'protectedIdentifierFieldValue', + ), + $this->lazyObject + ) + ->will($this->returnCallback(function ($id, LazyLoadableObject $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(); + $this->assertSame('publicIdentifierFieldValue-test', $this->lazyObject->publicAssociation); + } + + public function testFailedLoadingWillThrowException() + { + $this->proxyLoader->expects($this->any())->method('load')->will($this->returnValue(null)); + $this->setExpectedException('UnexpectedValueException'); + $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(array( + 'publicIdentifierField' => 'publicIdentifierFieldValue', + 'protectedIdentifierField' => 'protectedIdentifierFieldValue', + )) + ->will($this->returnCallback(function () { + $blueprint = new LazyLoadableObject(); + $blueprint->publicPersistentField = 'checked-persistent-field'; + $blueprint->publicAssociation = 'checked-association-field'; + $blueprint->publicTransientField = 'checked-transient-field'; + + return $blueprint; + })); + + $firstClone = clone $this->lazyObject; + $this->assertSame( + 'checked-persistent-field', + $firstClone->publicPersistentField, + 'Persistent fields are cloned correctly' + ); + $this->assertSame( + 'checked-association-field', + $firstClone->publicAssociation, + 'Associations are cloned correctly' + ); + $this->assertSame( + 'should-not-change', + $firstClone->publicTransientField, + 'Transient fields are not overwritten' + ); + + $secondClone = clone $this->lazyObject; + $this->assertSame( + 'checked-persistent-field', + $secondClone->publicPersistentField, + 'Persistent fields are cloned correctly' + ); + $this->assertSame( + 'checked-association-field', + $secondClone->publicAssociation, + 'Associations are cloned correctly' + ); + $this->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 $unserialized LazyLoadableObject|Proxy */ + $unserialized = unserialize($serialized); + $reflClass = $this->lazyLoadableObjectMetadata->getReflectionClass(); + + $this->assertFalse($unserialized->__isInitialized(), 'serialization didn\'t cause intialization'); + + // Checking identifiers + $this->assertSame('publicIdentifierFieldValue', $unserialized->publicIdentifierField, 'identifiers are kept'); + $protectedIdentifierField = $reflClass->getProperty('protectedIdentifierField'); + $protectedIdentifierField->setAccessible(true); + $this->assertSame( + 'protectedIdentifierFieldValue', + $protectedIdentifierField->getValue($unserialized), + 'identifiers are kept' + ); + + // Checking transient fields + $this->assertSame( + 'publicTransientFieldValue', + $unserialized->publicTransientField, + 'transient fields are kept' + ); + $protectedTransientField = $reflClass->getProperty('protectedTransientField'); + $protectedTransientField->setAccessible(true); + $this->assertSame( + 'protectedTransientFieldValue', + $protectedTransientField->getValue($unserialized), + 'transient fields are kept' + ); + + // Checking persistent fields + $this->assertSame( + 'publicPersistentFieldValue', + $unserialized->publicPersistentField, + 'persistent fields are kept' + ); + $protectedPersistentField = $reflClass->getProperty('protectedPersistentField'); + $protectedPersistentField->setAccessible(true); + $this->assertSame( + 'protectedPersistentFieldValue', + $protectedPersistentField->getValue($unserialized), + 'persistent fields are kept' + ); + + // Checking associations + $this->assertSame('publicAssociationValue', $unserialized->publicAssociation, 'associations are kept'); + $protectedAssociationField = $reflClass->getProperty('protectedAssociation'); + $protectedAssociationField->setAccessible(true); + $this->assertSame( + 'protectedAssociationValue', + $protectedAssociationField->getValue($unserialized), + 'associations are kept' + ); + } + + public function testInitializedProxyUnserialization() + { + // persister will retrieve the lazy object itself, so that we don't have to re-define all field values + $this->proxyLoader->expects($this->once())->method('load')->will($this->returnValue($this->lazyObject)); + $this->lazyObject->__setInitializer($this->getSuggestedInitializerImplementation()); + $this->lazyObject->__load(); + + $serialized = serialize($this->lazyObject); + $reflClass = $this->lazyLoadableObjectMetadata->getReflectionClass(); + /* @var $unserialized LazyLoadableObject|Proxy */ + $unserialized = unserialize($serialized); + + $this->assertTrue($unserialized->__isInitialized(), 'serialization didn\'t cause intialization'); + + // Checking transient fields + $this->assertSame( + 'publicTransientFieldValue', + $unserialized->publicTransientField, + 'transient fields are kept' + ); + $protectedTransientField = $reflClass->getProperty('protectedTransientField'); + $protectedTransientField->setAccessible(true); + $this->assertSame( + 'protectedTransientFieldValue', + $protectedTransientField->getValue($unserialized), + 'transient fields are kept' + ); + + // Checking persistent fields + $this->assertSame( + 'publicPersistentFieldValue', + $unserialized->publicPersistentField, + 'persistent fields are kept' + ); + $protectedPersistentField = $reflClass->getProperty('protectedPersistentField'); + $protectedPersistentField->setAccessible(true); + $this->assertSame( + 'protectedPersistentFieldValue', + $protectedPersistentField->getValue($unserialized), + 'persistent fields are kept' + ); + + // Checking identifiers + $this->assertSame( + 'publicIdentifierFieldValue', + $unserialized->publicIdentifierField, + 'identifiers are kept' + ); + $protectedIdentifierField = $reflClass->getProperty('protectedIdentifierField'); + $protectedIdentifierField->setAccessible(true); + $this->assertSame( + 'protectedIdentifierFieldValue', + $protectedIdentifierField->getValue($unserialized), + 'identifiers are kept' + ); + + // Checking associations + $this->assertSame('publicAssociationValue', $unserialized->publicAssociation, 'associations are kept'); + $protectedAssociationField = $reflClass->getProperty('protectedAssociation'); + $protectedAssociationField->setAccessible(true); + $this->assertSame( + 'protectedAssociationValue', + $protectedAssociationField->getValue($unserialized), + 'associations are kept' + ); + } + + public function testInitializationRestoresDefaultPublicLazyLoadedFieldValues() + { + // setting noop persister + $this->proxyLoader->expects($this->once())->method('load')->will($this->returnValue($this->lazyObject)); + $this->lazyObject->__setInitializer($this->getSuggestedInitializerImplementation()); + + $this->assertSame( + 'publicPersistentFieldValue', + $this->lazyObject->publicPersistentField, + 'Persistent field is restored to default value' + ); + $this->assertSame( + 'publicAssociationValue', + $this->lazyObject->publicAssociation, + 'Association is restored to default value' + ); + } + + public function testSettingPublicFieldsCausesLazyLoading() + { + $test = $this; + $this->configureInitializerMock( + 1, + array($this->lazyObject, '__set', array('publicPersistentField', 'newPublicPersistentFieldValue')), + function () use ($test) { + $test->setProxyValue('publicPersistentField', 'overrideValue'); + $test->setProxyValue('publicAssociation', 'newAssociationValue'); + } + ); + + $this->lazyObject->publicPersistentField = 'newPublicPersistentFieldValue'; + $this->assertSame('newPublicPersistentFieldValue', $this->lazyObject->publicPersistentField); + $this->assertSame('newAssociationValue', $this->lazyObject->publicAssociation); + } + + public function testSettingPublicAssociationCausesLazyLoading() + { + $test = $this; + $this->configureInitializerMock( + 1, + array($this->lazyObject, '__set', array('publicAssociation', 'newPublicAssociationValue')), + function () use ($test) { + $test->setProxyValue('publicPersistentField', 'newPublicPersistentFieldValue'); + $test->setProxyValue('publicAssociation', 'overrideValue'); + } + ); + + $this->lazyObject->publicAssociation = 'newPublicAssociationValue'; + $this->assertSame('newPublicAssociationValue', $this->lazyObject->publicAssociation); + $this->assertSame('newPublicPersistentFieldValue', $this->lazyObject->publicPersistentField); + } + + public function testCheckingPublicFieldsCausesLazyLoading() + { + $test = $this; + $this->configureInitializerMock( + 1, + array($this->lazyObject, '__isset', array('publicPersistentField')), + function () use ($test) { + $test->setProxyValue('publicPersistentField', null); + $test->setProxyValue('publicAssociation', 'setPublicAssociation'); + } + ); + + $this->assertFalse(isset($this->lazyObject->publicPersistentField)); + $this->assertNull($this->lazyObject->publicPersistentField); + $this->assertTrue(isset($this->lazyObject->publicAssociation)); + $this->assertSame('setPublicAssociation', $this->lazyObject->publicAssociation); + } + + public function testCheckingPublicAssociationCausesLazyLoading() + { + $test = $this; + $this->configureInitializerMock( + 1, + array($this->lazyObject, '__isset', array('publicAssociation')), + function () use ($test) { + $test->setProxyValue('publicPersistentField', 'newPersistentFieldValue'); + $test->setProxyValue('publicAssociation', 'setPublicAssociation'); + } + ); + + $this->assertTrue(isset($this->lazyObject->publicAssociation)); + $this->assertSame('setPublicAssociation', $this->lazyObject->publicAssociation); + $this->assertTrue(isset($this->lazyObject->publicPersistentField)); + $this->assertSame('newPersistentFieldValue', $this->lazyObject->publicPersistentField); + } + + /** + * Converts a given callable into a closure + * + * @param callable $callable + * @return \Closure + */ + public function getClosure($callable) { + return 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 array $callParamsMatch an ordered array of parameters to be expected + * @param callable $callbackClosure a return callback closure + * + * @return \PHPUnit_Framework_MockObject_MockObject| + */ + 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 (null !== $callParamsMatch) { + call_user_func_array(array($invocationMocker, 'with'), $callParamsMatch); + } + + if ($callbackClosure) { + $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 function (LazyLoadableObject $proxy) use ($loader, $identifier) { + /* @var $proxy LazyLoadableObject|Proxy */ + $proxy->__setInitializer(null); + $proxy->__setCloner(null); + + + if ($proxy->__isInitialized()) { + return; + } + + $properties = $proxy->__getLazyProperties(); + + foreach ($properties as $propertyName => $property) { + if (!isset($proxy->$propertyName)) { + $proxy->$propertyName = $properties[$propertyName]; + } + } + + $proxy->__setInitialized(true); + + if (method_exists($proxy, '__wakeup')) { + $proxy->__wakeup(); + } + + if (null === $loader->load($identifier, $proxy)) { + throw new \UnexpectedValueException('Couldn\'t load'); + } + }; + } +} \ No newline at end of file diff --git a/tests/Doctrine/Tests/Common/Proxy/SleepClass.php b/tests/Doctrine/Tests/Common/Proxy/SleepClass.php new file mode 100644 index 000000000..3c6ffcdba --- /dev/null +++ b/tests/Doctrine/Tests/Common/Proxy/SleepClass.php @@ -0,0 +1,19 @@ +getMock('stdClass', array('callGet')); + $getCheckMock->expects($this->never())->method('callGet'); + $initializer = function () use ($getCheckMock) { + call_user_func($getCheckMock); + }; + + $mockProxy = new RuntimePublicReflectionPropertyTestProxyMock(); + $mockProxy->__setInitializer($initializer); + + $reflProperty = new RuntimePublicReflectionProperty( + __NAMESPACE__ . '\RuntimePublicReflectionPropertyTestProxyMock', + 'checkedProperty' + ); + + $this->assertSame('testValue', $reflProperty->getValue($mockProxy)); + unset($mockProxy->checkedProperty); + $this->assertNull($reflProperty->getValue($mockProxy)); + } + + public function testSetValueOnProxyPublicProperty() + { + $setCheckMock = $this->getMock('stdClass', array('neverCallSet')); + $setCheckMock->expects($this->never())->method('neverCallSet'); + $initializer = function () use ($setCheckMock) { + call_user_func(array($setCheckMock, 'neverCallSet')); + }; + + $mockProxy = new RuntimePublicReflectionPropertyTestProxyMock(); + $mockProxy->__setInitializer($initializer); + + $reflProperty = new RuntimePublicReflectionProperty( + __NAMESPACE__ . '\RuntimePublicReflectionPropertyTestProxyMock', + 'checkedProperty' + ); + + $reflProperty->setValue($mockProxy, 'newValue'); + $this->assertSame('newValue', $mockProxy->checkedProperty); + + unset($mockProxy->checkedProperty); + $reflProperty->setValue($mockProxy, 'otherNewValue'); + $this->assertSame('otherNewValue', $mockProxy->checkedProperty); + + $setCheckMock = $this->getMock('stdClass', array('callSet')); + $setCheckMock->expects($this->once())->method('callSet'); + $initializer = function () use ($setCheckMock) { + call_user_func(array($setCheckMock, 'callSet')); + }; + + $mockProxy->__setInitializer($initializer); + $mockProxy->__setInitialized(true); + + unset($mockProxy->checkedProperty); + $reflProperty->setValue($mockProxy, 'againNewValue'); + $this->assertSame('againNewValue', $mockProxy->checkedProperty); + } +} + +/** + * Mock that simulates proxy public property lazy loading + */ +class RuntimePublicReflectionPropertyTestProxyMock implements Proxy +{ + /** + * @var \Closure|null + */ + private $initializer = null; + + /** + * @var \Closure|null + */ + private $initialized = false; + + /** + * @var string + */ + public $checkedProperty = 'testValue'; + + /** + * {@inheritDoc} + */ + public function __getInitializer() + { + return $this->initializer; + } + + /** + * {@inheritDoc} + */ + public function __setInitializer(\Closure $initializer = null) + { + $this->initializer = $initializer; + } + + /** + * {@inheritDoc} + */ + public function __getLazyProperties() + { + } + + /** + * {@inheritDoc} + */ + public function __load() + { + } + + /** + * {@inheritDoc} + */ + public function __isInitialized() + { + return $this->initialized; + } + + /** + * {@inheritDoc} + */ + public function __setInitialized($initialized) + { + $this->initialized = (bool) $initialized; + } + + /** + * @param string $name + */ + public function __get($name) + { + if ($this->initializer) { + $cb = $this->initializer; + $cb(); + } + + return $this->checkedProperty; + } + + /** + * @param string $name + * @param mixed $value + */ + public function __set($name, $value) + { + if ($this->initializer) { + $cb = $this->initializer; + $cb(); + } + + // triggers notices if `$name` is used: see https://bugs.php.net/bug.php?id=63463 + $this->checkedProperty = $value; + } + + /** + * @param string $name + * + * @return integer + */ + public function __isset($name) + { + if ($this->initializer) { + $cb = $this->initializer; + $cb(); + } + + return isset($this->checkedProperty); + } + + /** + * {@inheritDoc} + */ + public function __setCloner(\Closure $cloner = null) + { + } + + /** + * {@inheritDoc} + */ + public function __getCloner() + { + } +} \ No newline at end of file