From 21dd52a085e9ffdbe7a628eb616a7d18ea18aace Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 12 Jul 2023 09:18:21 +0200 Subject: [PATCH] Deprecate not-enabling lazy-ghosts and decouple from doctrine/common's proxies --- .../ORM/ORMInvalidArgumentException.php | 27 ++ lib/Doctrine/ORM/Proxy/ProxyFactory.php | 350 ++++++++++++++---- phpstan-baseline.neon | 19 +- psalm-baseline.xml | 41 +- 4 files changed, 364 insertions(+), 73 deletions(-) diff --git a/lib/Doctrine/ORM/ORMInvalidArgumentException.php b/lib/Doctrine/ORM/ORMInvalidArgumentException.php index e09114c0e7f..97ba4384c76 100644 --- a/lib/Doctrine/ORM/ORMInvalidArgumentException.php +++ b/lib/Doctrine/ORM/ORMInvalidArgumentException.php @@ -15,6 +15,7 @@ use function get_debug_type; use function gettype; use function implode; +use function is_scalar; use function method_exists; use function reset; use function spl_object_id; @@ -261,6 +262,32 @@ public static function invalidEntityName($entityName) return new self(sprintf('Entity name must be a string, %s given', get_debug_type($entityName))); } + /** @param mixed $value */ + public static function invalidAutoGenerateMode($value): self + { + return new self(sprintf('Invalid auto generate mode "%s" given.', is_scalar($value) ? (string) $value : get_debug_type($value))); + } + + public static function missingPrimaryKeyValue(string $className, string $idField): self + { + return new self(sprintf('Missing value for primary key %s on %s', $idField, $className)); + } + + public static function proxyDirectoryRequired(): self + { + return new self('You must configure a proxy directory. See docs for details'); + } + + public static function proxyNamespaceRequired(): self + { + return new self('You must configure a proxy namespace'); + } + + public static function proxyDirectoryNotWritable(string $proxyDirectory): self + { + return new self(sprintf('Your proxy directory "%s" must be writable', $proxyDirectory)); + } + /** * Helper method to show an object as string. * diff --git a/lib/Doctrine/ORM/Proxy/ProxyFactory.php b/lib/Doctrine/ORM/Proxy/ProxyFactory.php index 77a49061538..8ab454a408b 100644 --- a/lib/Doctrine/ORM/Proxy/ProxyFactory.php +++ b/lib/Doctrine/ORM/Proxy/ProxyFactory.php @@ -10,8 +10,10 @@ use Doctrine\Common\Proxy\ProxyDefinition; use Doctrine\Common\Proxy\ProxyGenerator; use Doctrine\Common\Util\ClassUtils; +use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityNotFoundException; +use Doctrine\ORM\ORMInvalidArgumentException; use Doctrine\ORM\Persisters\Entity\EntityPersister; use Doctrine\ORM\Proxy\Proxy as LegacyProxy; use Doctrine\ORM\UnitOfWork; @@ -19,20 +21,81 @@ use Doctrine\Persistence\Mapping\ClassMetadata; use ReflectionProperty; use Symfony\Component\VarExporter\ProxyHelper; -use Symfony\Component\VarExporter\VarExporter; use Throwable; +use function array_combine; use function array_flip; +use function array_intersect_key; +use function bin2hex; +use function chmod; +use function class_exists; +use function dirname; +use function file_exists; +use function file_put_contents; +use function filemtime; +use function is_bool; +use function is_dir; +use function is_int; +use function is_writable; +use function mkdir; +use function preg_match_all; +use function random_bytes; +use function rename; +use function rtrim; use function str_replace; use function strpos; +use function strrpos; +use function strtr; use function substr; -use function uksort; +use function ucfirst; + +use const DIRECTORY_SEPARATOR; +use const PHP_VERSION_ID; /** * This factory is used to create proxy objects for entities at runtime. */ class ProxyFactory extends AbstractProxyFactory { + /** + * Never autogenerate a proxy and rely that it was generated by some + * process before deployment. + */ + public const AUTOGENERATE_NEVER = 0; + + /** + * Always generates a new proxy in every request. + * + * This is only sane during development. + */ + public const AUTOGENERATE_ALWAYS = 1; + + /** + * Autogenerate the proxy class when the proxy file does not exist. + * + * This strategy causes a file_exists() call whenever any proxy is used the + * first time in a request. + */ + public const AUTOGENERATE_FILE_NOT_EXISTS = 2; + + /** + * Generate the proxy classes using eval(). + * + * This strategy is only sane for development, and even then it gives me + * the creeps a little. + */ + public const AUTOGENERATE_EVAL = 3; + + /** + * Autogenerate the proxy class when the proxy file does not exist or + * when the proxied file changed. + * + * This strategy causes a file_exists() call whenever any proxy is used the + * first time in a request. When the proxied file is changed, the proxy will + * be updated. + */ + public const AUTOGENERATE_FILE_NOT_EXISTS_OR_CHANGED = 4; + private const PROXY_CLASS_TEMPLATE = <<<'EOPHP' extends \ implements \ - public function __construct(?\Closure $initializer = null, ?\Closure $cloner = null) - { - if ($cloner !== null) { - return; - } - - self::createLazyGhost($initializer, , $this); - } - public function __isInitialized(): bool { return isset($this->lazyObjectState) && $this->isLazyObjectInitialized(); @@ -73,9 +127,15 @@ public function __serialize(): array /** @var UnitOfWork The UnitOfWork this factory uses to retrieve persisters */ private $uow; + /** @var string */ + private $proxyDir; + /** @var string */ private $proxyNs; + /** @var self::AUTOGENERATE_* */ + private $autoGenerate; + /** * The IdentifierFlattener used for manipulating identifiers * @@ -83,8 +143,11 @@ public function __serialize(): array */ private $identifierFlattener; - /** @var ProxyDefinition[] */ - private $definitions = []; + /** @var array */ + private $proxyFactories = []; + + /** @var bool */ + private $isLazyGhostObjectEnabled = true; /** * Initializes a new instance of the ProxyFactory class that is @@ -97,23 +160,40 @@ public function __serialize(): array */ public function __construct(EntityManagerInterface $em, $proxyDir, $proxyNs, $autoGenerate = self::AUTOGENERATE_NEVER) { - $proxyGenerator = new ProxyGenerator($proxyDir, $proxyNs); - - if ($em->getConfiguration()->isLazyGhostObjectEnabled()) { - $proxyGenerator->setPlaceholder('baseProxyInterface', InternalProxy::class); - $proxyGenerator->setPlaceholder('useLazyGhostTrait', Closure::fromCallable([$this, 'generateUseLazyGhostTrait'])); - $proxyGenerator->setPlaceholder('skippedProperties', Closure::fromCallable([$this, 'generateSkippedProperties'])); - $proxyGenerator->setPlaceholder('serializeImpl', Closure::fromCallable([$this, 'generateSerializeImpl'])); - $proxyGenerator->setProxyClassTemplate(self::PROXY_CLASS_TEMPLATE); - } else { + if (! $em->getConfiguration()->isLazyGhostObjectEnabled()) { + if (PHP_VERSION_ID >= 80100) { + Deprecation::trigger( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/10837/', + 'Not enabling lazy ghost objects is deprecated and will not be supported in Doctrine ORM 3.0. Ensure Doctrine\ORM\Configuration::setLazyGhostObjectEnabled(true) is called to enable them.' + ); + } + + $this->isLazyGhostObjectEnabled = false; + + $proxyGenerator = new ProxyGenerator($proxyDir, $proxyNs); $proxyGenerator->setPlaceholder('baseProxyInterface', LegacyProxy::class); + + parent::__construct($proxyGenerator, $em->getMetadataFactory(), $autoGenerate); } - parent::__construct($proxyGenerator, $em->getMetadataFactory(), $autoGenerate); + if (! $proxyDir) { + throw ORMInvalidArgumentException::proxyDirectoryRequired(); + } + + if (! $proxyNs) { + throw ORMInvalidArgumentException::proxyNamespaceRequired(); + } + + if (is_int($autoGenerate) ? $autoGenerate < 0 || $autoGenerate > 4 : ! is_bool($autoGenerate)) { + throw ORMInvalidArgumentException::invalidAutoGenerateMode($autoGenerate); + } $this->em = $em; $this->uow = $em->getUnitOfWork(); + $this->proxyDir = $proxyDir; $this->proxyNs = $proxyNs; + $this->autoGenerate = (int) $autoGenerate; $this->identifierFlattener = new IdentifierFlattener($this->uow, $em->getMetadataFactory()); } @@ -122,19 +202,57 @@ public function __construct(EntityManagerInterface $em, $proxyDir, $proxyNs, $au */ public function getProxy($className, array $identifier) { - $proxy = parent::getProxy($className, $identifier); + if (! $this->isLazyGhostObjectEnabled) { + return parent::getProxy($className, $identifier); + } - if (! $this->em->getConfiguration()->isLazyGhostObjectEnabled()) { - return $proxy; + $proxyFactory = $this->proxyFactories[$className] ?? $this->getProxyFactory($className); + + return $proxyFactory($identifier); + } + + /** + * Generates proxy classes for all given classes. + * + * @param ClassMetadata[] $classes The classes (ClassMetadata instances) for which to generate proxies. + * @param string|null $proxyDir The target directory of the proxy classes. If not specified, the + * directory configured on the Configuration of the EntityManager used + * by this factory is used. + * + * @return int Number of generated proxies. + */ + public function generateProxyClasses(array $classes, $proxyDir = null) + { + if (! $this->isLazyGhostObjectEnabled) { + return parent::generateProxyClasses($classes, $proxyDir); } - $initializer = $this->definitions[$className]->initializer; + $generated = 0; - $proxy->__construct(static function (InternalProxy $object) use ($initializer, $proxy): void { - $initializer($object, $proxy); - }); + foreach ($classes as $class) { + if ($this->skipClass($class)) { + continue; + } - return $proxy; + $proxyFileName = $this->getProxyFileName($class->getName(), $proxyDir ?: $this->proxyDir); + $proxyClassName = ClassUtils::generateProxyClassName($class->getName(), $this->proxyNs); + + $this->generateProxyClass($class, $proxyFileName, $proxyClassName); + + ++$generated; + } + + return $generated; + } + + /** + * {@inheritDoc} + * + * @deprecated ProxyFactory::resetUninitializedProxy() is deprecated and will be removed in version 3.0 of doctrine/orm. + */ + public function resetUninitializedProxy(CommonProxy $proxy) + { + return parent::resetUninitializedProxy($proxy); } /** @@ -149,22 +267,18 @@ protected function skipClass(ClassMetadata $metadata) /** * {@inheritDoc} + * + * @deprecated ProxyFactory::createProxyDefinition() is deprecated and will be removed in version 3.0 of doctrine/orm. */ protected function createProxyDefinition($className) { $classMetadata = $this->em->getClassMetadata($className); $entityPersister = $this->uow->getEntityPersister($className); - if ($this->em->getConfiguration()->isLazyGhostObjectEnabled()) { - $initializer = $this->createLazyInitializer($classMetadata, $entityPersister); - $cloner = static function (): void { - }; - } else { - $initializer = $this->createInitializer($classMetadata, $entityPersister); - $cloner = $this->createCloner($classMetadata, $entityPersister); - } + $initializer = $this->createInitializer($classMetadata, $entityPersister); + $cloner = $this->createCloner($classMetadata, $entityPersister); - return $this->definitions[$className] = new ProxyDefinition( + return new ProxyDefinition( ClassUtils::generateProxyClassName($className, $this->proxyNs), $classMetadata->getIdentifierFieldNames(), $classMetadata->getReflectionProperties(), @@ -176,6 +290,8 @@ protected function createProxyDefinition($className) /** * Creates a closure capable of initializing a proxy * + * @deprecated ProxyFactory::createInitializer() is deprecated and will be removed in version 3.0 of doctrine/orm. + * * @psalm-return Closure(CommonProxy):void * * @throws EntityNotFoundException @@ -241,16 +357,16 @@ private function createInitializer(ClassMetadata $classMetadata, EntityPersister * * @throws EntityNotFoundException */ - private function createLazyInitializer(ClassMetadata $classMetadata, EntityPersister $entityPersister): Closure + private function createLazyInitializer(ClassMetadata $classMetadata, EntityPersister $entityPersister, IdentifierFlattener $identifierFlattener): Closure { - return function (InternalProxy $proxy, InternalProxy $original) use ($entityPersister, $classMetadata): void { + return static function (InternalProxy $proxy, InternalProxy $original) use ($entityPersister, $classMetadata, $identifierFlattener): void { $identifier = $classMetadata->getIdentifierValues($original); $entity = $entityPersister->loadById($identifier, $original); if ($entity === null) { throw EntityNotFoundException::fromClassNameAndIdentifier( $classMetadata->getName(), - $this->identifierFlattener->flattenIdentifier($classMetadata, $identifier) + $identifierFlattener->flattenIdentifier($classMetadata, $identifier) ); } @@ -265,7 +381,6 @@ private function createLazyInitializer(ClassMetadata $classMetadata, EntityPersi continue; } - $property->setAccessible(true); $property->setValue($proxy, $property->getValue($entity)); } }; @@ -274,6 +389,8 @@ private function createLazyInitializer(ClassMetadata $classMetadata, EntityPersi /** * Creates a closure capable of finalizing state a cloned proxy * + * @deprecated ProxyFactory::createCloner() is deprecated and will be removed in version 3.0 of doctrine/orm. + * * @psalm-return Closure(CommonProxy):void * * @throws EntityNotFoundException @@ -310,38 +427,31 @@ private function createCloner(ClassMetadata $classMetadata, EntityPersister $ent }; } - private function generateUseLazyGhostTrait(ClassMetadata $class): string + private function getProxyFileName(string $className, string $baseDirectory): string { - $code = ProxyHelper::generateLazyGhost($class->getReflectionClass()); - $code = substr($code, 7 + (int) strpos($code, "\n{")); - $code = substr($code, 0, (int) strpos($code, "\n}")); - $code = str_replace('LazyGhostTrait;', str_replace("\n ", "\n", 'LazyGhostTrait { - initializeLazyObject as __load; - setLazyObjectAsInitialized as public __setInitialized; - isLazyObjectInitialized as private; - createLazyGhost as private; - resetLazyObject as private; - }'), $code); + $baseDirectory = $baseDirectory ?: $this->proxyDir; - return $code; + return rtrim($baseDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . InternalProxy::MARKER + . str_replace('\\', '', $className) . '.php'; } - private function generateSkippedProperties(ClassMetadata $class): string + private function getProxyFactory(string $className): Closure { $skippedProperties = []; + $class = $this->em->getClassMetadata($className); $identifiers = array_flip($class->getIdentifierFieldNames()); $filter = ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE; $reflector = $class->getReflectionClass(); while ($reflector) { foreach ($reflector->getProperties($filter) as $property) { - $name = $property->getName(); + $name = $property->name; if ($property->isStatic() || (($class->hasField($name) || $class->hasAssociation($name)) && ! isset($identifiers[$name]))) { continue; } - $prefix = $property->isPrivate() ? "\0" . $property->getDeclaringClass()->getName() . "\0" : ($property->isProtected() ? "\0*\0" : ''); + $prefix = $property->isPrivate() ? "\0" . $property->class . "\0" : ($property->isProtected() ? "\0*\0" : ''); $skippedProperties[$prefix . $name] = true; } @@ -350,11 +460,123 @@ private function generateSkippedProperties(ClassMetadata $class): string $reflector = $reflector->getParentClass(); } - uksort($skippedProperties, 'strnatcmp'); + $className = $class->getName(); // aliases and case sensitivity + $entityPersister = $this->uow->getEntityPersister($className); + $initializer = $this->createLazyInitializer($class, $entityPersister, $this->identifierFlattener); + $proxyClassName = $this->loadProxyClass($class); + $identifierFields = array_intersect_key($class->getReflectionProperties(), $identifiers); + + $proxyFactory = Closure::bind(static function (array $identifier) use ($initializer, $skippedProperties, $identifierFields, $className): InternalProxy { + $proxy = self::createLazyGhost(static function (InternalProxy $object) use ($initializer, &$proxy): void { + $initializer($object, $proxy); + }, $skippedProperties); + + foreach ($identifierFields as $idField => $reflector) { + if (! isset($identifier[$idField])) { + throw ORMInvalidArgumentException::missingPrimaryKeyValue($className, $idField); + } + + $reflector->setValue($proxy, $identifier[$idField]); + } + + return $proxy; + }, null, $proxyClassName); + + return $this->proxyFactories[$className] = $proxyFactory; + } + + private function loadProxyClass(ClassMetadata $class): string + { + $proxyClassName = ClassUtils::generateProxyClassName($class->getName(), $this->proxyNs); + + if (class_exists($proxyClassName, false)) { + return $proxyClassName; + } + + if ($this->autoGenerate === self::AUTOGENERATE_EVAL) { + $this->generateProxyClass($class, null, $proxyClassName); + + return $proxyClassName; + } + + $fileName = $this->getProxyFileName($class->getName(), $this->proxyDir); + + switch ($this->autoGenerate) { + case self::AUTOGENERATE_FILE_NOT_EXISTS_OR_CHANGED: + if (file_exists($fileName) && filemtime($fileName) >= filemtime($class->getReflectionClass()->getFileName())) { + break; + } + // no break + case self::AUTOGENERATE_FILE_NOT_EXISTS: + if (file_exists($fileName)) { + break; + } + // no break + case self::AUTOGENERATE_ALWAYS: + $this->generateProxyClass($class, $fileName, $proxyClassName); + break; + } + + require $fileName; - $code = VarExporter::export($skippedProperties); - $code = str_replace(VarExporter::export($class->getName()), 'parent::class', $code); - $code = str_replace("\n", "\n ", $code); + return $proxyClassName; + } + + private function generateProxyClass(ClassMetadata $class, ?string $fileName, string $proxyClassName): void + { + $i = strrpos($proxyClassName, '\\'); + $placeholders = [ + '' => $class->getName(), + '' => substr($proxyClassName, 0, $i), + '' => substr($proxyClassName, 1 + $i), + '' => InternalProxy::class, + ]; + + preg_match_all('(<([a-zA-Z]+)>)', self::PROXY_CLASS_TEMPLATE, $placeholderMatches); + + foreach (array_combine($placeholderMatches[0], $placeholderMatches[1]) as $placeholder => $name) { + $placeholders[$placeholder] ?? $placeholders[$placeholder] = $this->{'generate' . ucfirst($name)}($class); + } + + $proxyCode = strtr(self::PROXY_CLASS_TEMPLATE, $placeholders); + + if (! $fileName) { + if (! class_exists($proxyClassName)) { + eval(substr($proxyCode, 5)); + } + + return; + } + + $parentDirectory = dirname($fileName); + + if (! is_dir($parentDirectory) && ! @mkdir($parentDirectory, 0775, true)) { + throw ORMInvalidArgumentException::proxyDirectoryNotWritable($this->proxyDir); + } + + if (! is_writable($parentDirectory)) { + throw ORMInvalidArgumentException::proxyDirectoryNotWritable($this->proxyDir); + } + + $tmpFileName = $fileName . '.' . bin2hex(random_bytes(12)); + + file_put_contents($tmpFileName, $proxyCode); + @chmod($tmpFileName, 0664); + rename($tmpFileName, $fileName); + } + + private function generateUseLazyGhostTrait(ClassMetadata $class): string + { + $code = ProxyHelper::generateLazyGhost($class->getReflectionClass()); + $code = substr($code, 7 + (int) strpos($code, "\n{")); + $code = substr($code, 0, (int) strpos($code, "\n}")); + $code = str_replace('LazyGhostTrait;', str_replace("\n ", "\n", 'LazyGhostTrait { + initializeLazyObject as __load; + setLazyObjectAsInitialized as public __setInitialized; + isLazyObjectInitialized as private; + createLazyGhost as private; + resetLazyObject as private; + }'), $code); return $code; } @@ -365,7 +587,7 @@ private function generateSerializeImpl(ClassMetadata $class): string $properties = $reflector->hasMethod('__serialize') ? 'parent::__serialize()' : '(array) $this'; $code = '$properties = ' . $properties . '; - unset($properties["\0" . self::class . "\0lazyObjectState"], $properties[\'__isCloning\']); + unset($properties["\0" . self::class . "\0lazyObjectState"]); '; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 87d9dc9837c..10e6a2dac50 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -286,12 +286,22 @@ parameters: path: lib/Doctrine/ORM/Proxy/ProxyFactory.php - - message: "#^Call to an undefined method Doctrine\\\\Common\\\\Proxy\\\\Proxy\\:\\:__construct\\(\\)\\.$#" + message: "#^Call to an undefined method Doctrine\\\\Common\\\\Proxy\\\\Proxy\\:\\:__wakeup\\(\\)\\.$#" count: 1 path: lib/Doctrine/ORM/Proxy/ProxyFactory.php - - message: "#^Call to an undefined method Doctrine\\\\Common\\\\Proxy\\\\Proxy\\:\\:__wakeup\\(\\)\\.$#" + message: "#^Call to an undefined static method Doctrine\\\\ORM\\\\Proxy\\\\ProxyFactory\\:\\:createLazyGhost\\(\\)\\.$#" + count: 1 + path: lib/Doctrine/ORM/Proxy/ProxyFactory.php + + - + message: "#^Comparison operation \"\\<\" between 0\\|1\\|2\\|3\\|4 and 0 is always false\\.$#" + count: 1 + path: lib/Doctrine/ORM/Proxy/ProxyFactory.php + + - + message: "#^Comparison operation \"\\>\" between 0\\|1\\|2\\|3\\|4 and 4 is always false\\.$#" count: 1 path: lib/Doctrine/ORM/Proxy/ProxyFactory.php @@ -300,6 +310,11 @@ parameters: count: 3 path: lib/Doctrine/ORM/Proxy/ProxyFactory.php + - + message: "#^Result of \\|\\| is always false\\.$#" + count: 1 + path: lib/Doctrine/ORM/Proxy/ProxyFactory.php + - message: "#^Parameter \\#2 \\$sqlParams of method Doctrine\\\\ORM\\\\Query\\:\\:evictResultSetCache\\(\\) expects array\\, array\\ given\\.$#" count: 1 diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 5e439ed0663..16ae4c9e663 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1386,32 +1386,59 @@ $classMetadata $classMetadata - - __construct(static function (InternalProxy $object) use ($initializer, $proxy): void { - $initializer($object, $proxy); - })]]> - + + createCloner + createInitializer + getReflectionProperties()]]> getMetadataFactory()]]> getMetadataFactory()]]> + + Closure + + + proxyFactories]]> + isEmbeddedClass]]> isMappedSuperclass]]> + + proxyFactories[$className] = $proxyFactory]]> + + + $i + + + $i + name]]> name]]> + getValue setAccessible - setAccessible + setValue + setValue + + + 4]]> + - __construct __wakeup + + + + + require $fileName +