diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 42df06e5361..75949cd0907 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -43,6 +43,8 @@ jobs: - "default" extension: - "pdo_sqlite" + proxy: + - "common" include: - php-version: "8.0" dbal-version: "2.13" @@ -53,6 +55,10 @@ jobs: - php-version: "8.1" dbal-version: "3@dev" extension: "sqlite3" + - php-version: "8.1" + dbal-version: "3@dev" + proxy: "lazy-ghost" + extension: "pdo_sqlite" steps: - name: "Checkout" @@ -72,6 +78,10 @@ jobs: run: "composer require doctrine/dbal ^${{ matrix.dbal-version }} --no-update" if: "${{ matrix.dbal-version != 'default' }}" + - name: "Require specific lazy-proxy implementation" + run: "composer require symfony/var-exporter:^6.2@dev doctrine/persistence:^3.1@dev --no-update" + if: "${{ matrix.proxy == 'lazy-ghost' }}" + - name: "Install dependencies with Composer" uses: "ramsey/composer-install@v2" with: diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index fd5a67aee69..217d5a3e4eb 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -60,8 +60,7 @@ jobs: if: "${{ matrix.dbal-version != 'default' }}" - name: "Require specific persistence version" - run: "composer require doctrine/persistence ^${{ matrix.persistence-version }} --no-update" - if: "${{ matrix.persistence-version != 'default' }}" + run: "composer require symfony/var-exporter ^6.2@dev doctrine/persistence ^$([ ${{ matrix.persistence-version }} = default ] && echo '3.1@dev' || echo ${{ matrix.persistence-version }}) --no-update" - name: "Install dependencies with Composer" uses: "ramsey/composer-install@v2" @@ -100,6 +99,9 @@ jobs: coverage: "none" php-version: "${{ matrix.php-version }}" + - name: "Require specific persistence version" + run: "composer require symfony/var-exporter ^6.2@dev doctrine/persistence ^3.1@dev --no-update" + - name: "Install dependencies with Composer" uses: "ramsey/composer-install@v2" with: diff --git a/docs/en/cookbook/accessing-private-properties-of-the-same-class-from-different-instance.rst b/docs/en/cookbook/accessing-private-properties-of-the-same-class-from-different-instance.rst deleted file mode 100644 index f6a1b423f5a..00000000000 --- a/docs/en/cookbook/accessing-private-properties-of-the-same-class-from-different-instance.rst +++ /dev/null @@ -1,74 +0,0 @@ -Accessing private/protected properties/methods of the same class from different instance -======================================================================================== - -.. sectionauthor:: Michael Olsavsky (olsavmic) - -As explained in the :doc:`restrictions for entity classes in the manual <../reference/architecture>`, -it is dangerous to access private/protected properties of different entity instance of the same class because of lazy loading. - -The proxy instance that's injected instead of the real entity may not be initialized yet -and therefore not contain expected data which may result in unexpected behavior. -That's a limitation of current proxy implementation - only public methods automatically initialize proxies. - -It is usually preferable to use a public interface to manipulate the object from outside the `$this` -context but it may not be convenient in some cases. The following example shows how to do it safely. - -Safely accessing private properties from different instance of the same class ------------------------------------------------------------------------------ - -To safely access private property of different instance of the same class, make sure to initialise -the proxy before use manually as follows: - -.. code-block:: php - - parent instanceof Proxy) { - $this->parent->__load(); - } - - // Accessing the `$this->parent->name` property without loading the proxy first - // may throw error in case the Proxy has not been initialized yet. - $this->parent->name; - } - - public function doSomethingWithAnotherInstance(self $instance) - { - // Always initializing the proxy before use - if ($instance instanceof Proxy) { - $instance->__load(); - } - - // Accessing the `$instance->name` property without loading the proxy first - // may throw error in case the Proxy has not been initialized yet. - $instance->name; - } - - // ... - } diff --git a/docs/en/cookbook/implementing-wakeup-or-clone.rst b/docs/en/cookbook/implementing-wakeup-or-clone.rst deleted file mode 100644 index c65a9a62216..00000000000 --- a/docs/en/cookbook/implementing-wakeup-or-clone.rst +++ /dev/null @@ -1,78 +0,0 @@ -Implementing Wakeup or Clone -============================ - -.. sectionauthor:: Roman Borschel (roman@code-factory.org) - -As explained in the :ref:`restrictions for entity classes in the manual -`, -it is usually not allowed for an entity to implement ``__wakeup`` -or ``__clone``, because Doctrine makes special use of them. -However, it is quite easy to make use of these methods in a safe -way by guarding the custom wakeup or clone code with an entity -identity check, as demonstrated in the following sections. - -Safely implementing __wakeup ----------------------------- - -To safely implement ``__wakeup``, simply enclose your -implementation code in an identity check as follows: - -.. code-block:: php - - id) { - // ... Your code here as normal ... - } - // otherwise do nothing, do NOT throw an exception! - } - - // ... - } - -Safely implementing __clone ---------------------------- - -Safely implementing ``__clone`` is pretty much the same: - -.. code-block:: php - - id) { - // ... Your code here as normal ... - } - // otherwise do nothing, do NOT throw an exception! - } - - // ... - } - -Summary -------- - -As you have seen, it is quite easy to safely make use of -``__wakeup`` and ``__clone`` in your entities without adding any -really Doctrine-specific or Doctrine-dependant code. - -These implementations are possible and safe because when Doctrine -invokes these methods, the entities never have an identity (yet). -Furthermore, it is possibly a good idea to check for the identity -in your code anyway, since it's rarely the case that you want to -unserialize or clone an entity with no identity. - - diff --git a/docs/en/index.rst b/docs/en/index.rst index d0e16d22b3b..effca58d9a9 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -112,7 +112,6 @@ Cookbook * **Implementation**: :doc:`Array Access ` | :doc:`Notify ChangeTracking Example ` | - :doc:`Using Wakeup Or Clone ` | :doc:`Working with DateTime ` | :doc:`Validation ` | :doc:`Entities in the Session ` | diff --git a/docs/en/reference/advanced-configuration.rst b/docs/en/reference/advanced-configuration.rst index 499463ed31a..f6710c8a9f3 100644 --- a/docs/en/reference/advanced-configuration.rst +++ b/docs/en/reference/advanced-configuration.rst @@ -325,8 +325,9 @@ identifier. You could simply do this: $cart->addItem($item); Here, we added an Item to a Cart without loading the Item from the -database. If you invoke any method on the Item instance, it would -fully initialize its state transparently from the database. Here +database. If you access any state that isn't yet available in the +Item instance, the proxying mechanism would fully initialize the +object's state transparently from the database. Here $item is actually an instance of the proxy class that was generated for the Item class but your code does not need to care. In fact it **should not care**. Proxy objects should be transparent to your diff --git a/docs/en/reference/architecture.rst b/docs/en/reference/architecture.rst index 74a5950434f..3bd844dd8ca 100644 --- a/docs/en/reference/architecture.rst +++ b/docs/en/reference/architecture.rst @@ -74,32 +74,13 @@ Entities An entity is a lightweight, persistent domain object. An entity can be any regular PHP class observing the following restrictions: - -- An entity class must not be final or contain final methods. -- All persistent properties/field of any entity class should - always be private or protected, otherwise lazy-loading might not - work as expected. In case you serialize entities (for example Session) - properties should be protected (See Serialize section below). -- An entity class must not implement ``__clone`` or - :doc:`do so safely <../cookbook/implementing-wakeup-or-clone>`. -- An entity class must not implement ``__wakeup`` or - :doc:`do so safely <../cookbook/implementing-wakeup-or-clone>`. - You can also consider implementing - `Serializable `_, - but be aware that it is deprecated since PHP 8.1. We do not recommend its usage. -- PHP 7.4 introduces :doc:`the new magic method ` - ``__unserialize``, which changes the execution priority between - ``__wakeup`` and itself when used. This can cause unexpected behaviour in - an Entity. +- An entity class must not be final nor read-only but + it may contain final methods or read-only properties. - Any two entity classes in a class hierarchy that inherit directly or indirectly from one another must not have a mapped property with the same name. That is, if B inherits from A then B must not have a mapped field with the same name as an already mapped field that is inherited from A. -- An entity cannot make use of func_get_args() to implement variable parameters. - Generated proxies do not support this for performance reasons and your code might - actually fail to work when violating this restriction. -- Entity cannot access private/protected properties/methods of another entity of the same class or :doc:`do so safely <../cookbook/accessing-private-properties-of-the-same-class-from-different-instance>`. Entities support inheritance, polymorphic associations, and polymorphic queries. Both abstract and concrete classes can be @@ -159,17 +140,13 @@ Serializing entities Serializing entities can be problematic and is not really recommended, at least not as long as an entity instance still holds -references to proxy objects or is still managed by an -EntityManager. If you intend to serialize (and unserialize) entity -instances that still hold references to proxy objects you may run -into problems with private properties because of technical -limitations. Proxy objects implement ``__sleep`` and it is not -possible for ``__sleep`` to return names of private properties in -parent classes. On the other hand it is not a solution for proxy -objects to implement ``Serializable`` because Serializable does not -work well with any potential cyclic object references (at least we -did not find a way yet, if you did, please contact us). The -``Serializable`` interface is also deprecated beginning with PHP 8.1. +references to proxy objects or is still managed by an EntityManager. +By default, serializing proxy objects does not initialize them. On +unserialization, resulting objects are detached from the entity +manager and cannot be initialiazed anymore. You can implement the +``__serialize()`` method if you want to change that behavior, but +then you need to ensure that you won't generate large serialized +object graphs and take care of circular associations. The EntityManager ~~~~~~~~~~~~~~~~~ diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index 1af12432a3e..024d1b38f77 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -180,10 +180,10 @@ not need to lazy load the association with another query. Doctrine allows you to walk all the associations between all the objects in your domain model. Objects that were not already - loaded from the database are replaced with lazy load proxy - instances. Non-loaded Collections are also replaced by lazy-load + loaded from the database are replaced with lazy-loading proxy + instances. Non-loaded Collections are also replaced by lazy-loading instances that fetch all the contained objects upon first access. - However relying on the lazy-load mechanism leads to many small + However relying on the lazy-loading mechanism leads to many small queries executed against the database, which can significantly affect the performance of your application. **Fetch Joins** are the solution to hydrate most or all of the entities that you need in a diff --git a/docs/en/reference/limitations-and-known-issues.rst b/docs/en/reference/limitations-and-known-issues.rst index 61c1e06bb8c..fa0f2be094f 100644 --- a/docs/en/reference/limitations-and-known-issues.rst +++ b/docs/en/reference/limitations-and-known-issues.rst @@ -177,27 +177,3 @@ MySQL with MyISAM tables Doctrine cannot provide atomic operations when calling ``EntityManager#flush()`` if one of the tables involved uses the storage engine MyISAM. You must use InnoDB or other storage engines that support transactions if you need integrity. - -Entities, Proxies and Reflection -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Using methods for Reflection on entities can be prone to error, when the entity -is actually a proxy the following methods will not work correctly: - -- ``new ReflectionClass`` -- ``new ReflectionObject`` -- ``get_class()`` -- ``get_parent_class()`` - -This is why ``Doctrine\Common\Util\ClassUtils`` class exists that has similar -methods, which resolve the proxy problem beforehand. - -.. code-block:: php - - getReference('Acme\Book'); - - $reflection = ClassUtils::newReflectionClass($bookProxy); - $class = ClassUtils::getClass($bookProxy)¸ diff --git a/docs/en/reference/working-with-objects.rst b/docs/en/reference/working-with-objects.rst index 203367039df..fb1cc1ae434 100644 --- a/docs/en/reference/working-with-objects.rst +++ b/docs/en/reference/working-with-objects.rst @@ -162,28 +162,6 @@ your code. See the following code: echo "This will always be true!"; } -A slice of the generated proxy classes code looks like the -following piece of code. A real proxy class override ALL public -methods along the lines of the ``getName()`` method shown below: - -.. code-block:: php - - _load(); - return parent::getName(); - } - // .. other public methods of User - } - .. warning:: Traversing the object graph for parts that are lazy-loaded will @@ -414,14 +392,6 @@ Example: // $entity now refers to the fully managed copy returned by the merge operation. // The EntityManager $em now manages the persistence of $entity as usual. -.. note:: - - When you want to serialize/unserialize entities you - have to make all entity properties protected, never private. The - reason for this is, if you serialize a class that was a proxy - instance before, the private variables won't be serialized and a - PHP Notice is thrown. - The semantics of the merge operation, applied to an entity X, are as follows: diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst index 26c3a702d31..0430bcc7504 100644 --- a/docs/en/sidebar.rst +++ b/docs/en/sidebar.rst @@ -73,7 +73,6 @@ cookbook/dql-user-defined-functions cookbook/implementing-arrayaccess-for-domain-objects cookbook/implementing-the-notify-changetracking-policy - cookbook/implementing-wakeup-or-clone cookbook/resolve-target-entity-listener cookbook/sql-table-prefixes cookbook/strategy-cookbook-introduction diff --git a/docs/en/toc.rst b/docs/en/toc.rst index f5e9330ad5b..fa92cf38021 100644 --- a/docs/en/toc.rst +++ b/docs/en/toc.rst @@ -75,7 +75,6 @@ Cookbook cookbook/dql-user-defined-functions cookbook/implementing-arrayaccess-for-domain-objects cookbook/implementing-the-notify-changetracking-policy - cookbook/implementing-wakeup-or-clone cookbook/resolve-target-entity-listener cookbook/sql-table-prefixes cookbook/strategy-cookbook-introduction diff --git a/docs/en/tutorials/getting-started.rst b/docs/en/tutorials/getting-started.rst index d638c8ecec4..12e24a1da13 100644 --- a/docs/en/tutorials/getting-started.rst +++ b/docs/en/tutorials/getting-started.rst @@ -43,14 +43,15 @@ What are Entities? Entities are PHP Objects that can be identified over many requests by a unique identifier or primary key. These classes don't need to extend any -abstract base class or interface. An entity class must not be final -or contain final methods. Additionally it must not implement -**clone** nor **wakeup**, unless it :doc:`does so safely <../cookbook/implementing-wakeup-or-clone>`. +abstract base class or interface. An entity contains persistable properties. A persistable property is an instance variable of the entity that is saved into and retrieved from the database by Doctrine's data mapping capabilities. +An entity class must not be final nor read-only, although +it can contain final methods or read-only properties. + An Example Model: Bug Tracker ----------------------------- @@ -889,18 +890,6 @@ domain model to match the requirements: understand the changes that have happened to the collection that are noteworthy for persistence. -.. warning:: - - Lazy load proxies always contain an instance of - Doctrine's EntityManager and all its dependencies. Therefore a - ``var_dump()`` will possibly dump a very large recursive structure - which is impossible to render and read. You have to use - ``Doctrine\Common\Util\Debug::dump()`` to restrict the dumping to a - human readable level. Additionally you should be aware that dumping - the EntityManager to a Browser may take several minutes, and the - ``Debug::dump()`` method just ignores any occurrences of it in Proxy - instances. - Because we only work with collections for the references we must be careful to implement a bidirectional reference in the domain model. The concept of owning or inverse side of a relation is central to @@ -1589,39 +1578,8 @@ The output of the engineer’s name is fetched from the database! What is happen Since we only retrieved the bug by primary key both the engineer and reporter are not immediately loaded from the database but are replaced by LazyLoading -proxies. These proxies will load behind the scenes, when the first method -is called on them. - -Sample code of this proxy generated code can be found in the specified Proxy -Directory, it looks like: - -.. code-block:: php - - _load(); - return parent::addReportedBug($bug); - } - - public function assignedToBug($bug) - { - $this->_load(); - return parent::assignedToBug($bug); - } - } - -See how upon each method call the proxy is lazily loaded from the -database? +proxies. These proxies will load behind the scenes, when attempting to access +any of their un-initialized state. The call prints: diff --git a/lib/Doctrine/ORM/Configuration.php b/lib/Doctrine/ORM/Configuration.php index abf4829c4fc..4d6e3e2f41e 100644 --- a/lib/Doctrine/ORM/Configuration.php +++ b/lib/Doctrine/ORM/Configuration.php @@ -43,14 +43,17 @@ use Doctrine\ORM\Repository\RepositoryFactory; use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Doctrine\Persistence\ObjectRepository; +use Doctrine\Persistence\Reflection\RuntimeReflectionProperty; use LogicException; use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\VarExporter\LazyGhostTrait; use function class_exists; use function is_a; use function method_exists; use function sprintf; use function strtolower; +use function trait_exists; use function trim; /** @@ -1072,4 +1075,28 @@ public function setSchemaIgnoreClasses(array $schemaIgnoreClasses): void { $this->_attributes['schemaIgnoreClasses'] = $schemaIgnoreClasses; } + + public function isLazyGhostObjectEnabled(): bool + { + return $this->_attributes['isLazyGhostObjectEnabled'] ?? false; + } + + public function setLazyGhostObjectEnabled(bool $flag): void + { + if ($flag && ! trait_exists(LazyGhostTrait::class)) { + throw new LogicException(sprintf( + 'Lazy ghost objects cannot be enabled because the "symfony/var-exporter" library' + . ' version 6.2 or higher is not installed. Please run "composer require symfony/var-exporter:^6.2".' + )); + } + + if ($flag && ! class_exists(RuntimeReflectionProperty::class)) { + throw new LogicException(sprintf( + 'Lazy ghost objects cannot be enabled because the "doctrine/persistence" library' + . ' version 3.1 or higher is not installed. Please run "composer update doctrine/persistence".' + )); + } + + $this->_attributes['isLazyGhostObjectEnabled'] = $flag; + } } diff --git a/lib/Doctrine/ORM/Internal/Hydration/SimpleObjectHydrator.php b/lib/Doctrine/ORM/Internal/Hydration/SimpleObjectHydrator.php index 454330290eb..353c65d3bd7 100644 --- a/lib/Doctrine/ORM/Internal/Hydration/SimpleObjectHydrator.php +++ b/lib/Doctrine/ORM/Internal/Hydration/SimpleObjectHydrator.php @@ -148,10 +148,6 @@ protected function hydrateRowData(array $row, array &$result) } } - if (isset($this->_hints[Query::HINT_REFRESH_ENTITY])) { - $this->registerManaged($this->class, $this->_hints[Query::HINT_REFRESH_ENTITY], $data); - } - $uow = $this->_em->getUnitOfWork(); $entity = $uow->createEntity($entityName, $data, $this->_hints); diff --git a/lib/Doctrine/ORM/Proxy/ProxyFactory.php b/lib/Doctrine/ORM/Proxy/ProxyFactory.php index ffa87ccbb35..bcb6c4e12f1 100644 --- a/lib/Doctrine/ORM/Proxy/ProxyFactory.php +++ b/lib/Doctrine/ORM/Proxy/ProxyFactory.php @@ -6,16 +6,26 @@ use Closure; use Doctrine\Common\Proxy\AbstractProxyFactory; -use Doctrine\Common\Proxy\Proxy as BaseProxy; +use Doctrine\Common\Proxy\Proxy as CommonProxy; use Doctrine\Common\Proxy\ProxyDefinition; use Doctrine\Common\Proxy\ProxyGenerator; use Doctrine\Common\Util\ClassUtils; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityNotFoundException; use Doctrine\ORM\Persisters\Entity\EntityPersister; +use Doctrine\ORM\Proxy\Proxy as LegacyProxy; use Doctrine\ORM\UnitOfWork; use Doctrine\ORM\Utility\IdentifierFlattener; use Doctrine\Persistence\Mapping\ClassMetadata; +use Doctrine\Persistence\Proxy; +use Symfony\Component\VarExporter\ProxyHelper; +use Symfony\Component\VarExporter\VarExporter; + +use function array_flip; +use function str_replace; +use function strpos; +use function substr; +use function uksort; /** * This factory is used to create proxy objects for entities at runtime. @@ -24,6 +34,52 @@ */ class ProxyFactory extends AbstractProxyFactory { + private const PROXY_CLASS_TEMPLATE = <<<'EOPHP' +; + +/** + * DO NOT EDIT THIS FILE - IT WAS CREATED BY DOCTRINE\'S PROXY GENERATOR + */ +class extends \ implements \ +{ + + + /** + * @internal + */ + public bool $__isCloning = false; + + public function __construct(?\Closure $initializer = null) + { + self::createLazyGhost($initializer, , $this); + } + + public function __isInitialized(): bool + { + return isset($this->lazyObjectId) && $this->isLazyObjectInitialized(); + } + + public function __clone() + { + $this->__isCloning = true; + + try { + $this->__doClone(); + } finally { + $this->__isCloning = false; + } + } + + public function __serialize(): array + { + + } +} + +EOPHP; + /** @var EntityManagerInterface The EntityManager this factory is bound to. */ private $em; @@ -55,7 +111,16 @@ public function __construct(EntityManagerInterface $em, $proxyDir, $proxyNs, $au { $proxyGenerator = new ProxyGenerator($proxyDir, $proxyNs); - $proxyGenerator->setPlaceholder('baseProxyInterface', Proxy::class); + if ($em->getConfiguration()->isLazyGhostObjectEnabled()) { + $proxyGenerator->setPlaceholder('baseProxyInterface', Proxy::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 { + $proxyGenerator->setPlaceholder('baseProxyInterface', LegacyProxy::class); + } + parent::__construct($proxyGenerator, $em->getMetadataFactory(), $autoGenerate); $this->em = $em; @@ -82,19 +147,28 @@ 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); + } + return new ProxyDefinition( ClassUtils::generateProxyClassName($className, $this->proxyNs), $classMetadata->getIdentifierFieldNames(), $classMetadata->getReflectionProperties(), - $this->createInitializer($classMetadata, $entityPersister), - $this->createCloner($classMetadata, $entityPersister) + $initializer, + $cloner ); } /** * Creates a closure capable of initializing a proxy * - * @psalm-return Closure(BaseProxy):void + * @psalm-return Closure(CommonProxy):void * * @throws EntityNotFoundException */ @@ -102,7 +176,7 @@ private function createInitializer(ClassMetadata $classMetadata, EntityPersister { $wakeupProxy = $classMetadata->getReflectionClass()->hasMethod('__wakeup'); - return function (BaseProxy $proxy) use ($entityPersister, $classMetadata, $wakeupProxy): void { + return function (CommonProxy $proxy) use ($entityPersister, $classMetadata, $wakeupProxy): void { $initializer = $proxy->__getInitializer(); $cloner = $proxy->__getCloner(); @@ -142,16 +216,53 @@ private function createInitializer(ClassMetadata $classMetadata, EntityPersister }; } + /** + * Creates a closure capable of initializing a proxy + * + * @return Closure(Proxy):void + * + * @throws EntityNotFoundException + */ + private function createLazyInitializer(ClassMetadata $classMetadata, EntityPersister $entityPersister): Closure + { + return function (Proxy $proxy) use ($entityPersister, $classMetadata): void { + $identifier = $classMetadata->getIdentifierValues($proxy); + $entity = $entityPersister->loadById($identifier, $proxy->__isCloning ? null : $proxy); + + if ($entity === null) { + throw EntityNotFoundException::fromClassNameAndIdentifier( + $classMetadata->getName(), + $this->identifierFlattener->flattenIdentifier($classMetadata, $identifier) + ); + } + + if (! $proxy->__isCloning) { + return; + } + + $class = $entityPersister->getClassMetadata(); + + foreach ($class->getReflectionProperties() as $property) { + if (! $class->hasField($property->name) && ! $class->hasAssociation($property->name)) { + continue; + } + + $property->setAccessible(true); + $property->setValue($proxy, $property->getValue($entity)); + } + }; + } + /** * Creates a closure capable of finalizing state a cloned proxy * - * @psalm-return Closure(BaseProxy):void + * @psalm-return Closure(CommonProxy):void * * @throws EntityNotFoundException */ private function createCloner(ClassMetadata $classMetadata, EntityPersister $entityPersister): Closure { - return function (BaseProxy $proxy) use ($entityPersister, $classMetadata): void { + return function (CommonProxy $proxy) use ($entityPersister, $classMetadata): void { if ($proxy->__isInitialized()) { return; } @@ -180,4 +291,76 @@ private function createCloner(ClassMetadata $classMetadata, EntityPersister $ent } }; } + + 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; + __clone as private __doClone; + }'), $code); + + return $code; + } + + private function generateSkippedProperties(ClassMetadata $class): string + { + $skippedProperties = ['__isCloning' => true]; + $identifiers = array_flip($class->getIdentifierFieldNames()); + + foreach ($class->getReflectionClass()->getProperties() as $property) { + $name = $property->getName(); + + 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" : ''); + + $skippedProperties[$prefix . $name] = true; + } + + uksort($skippedProperties, 'strnatcmp'); + + $code = VarExporter::export($skippedProperties); + $code = str_replace(VarExporter::export($class->getName()), 'parent::class', $code); + $code = str_replace("\n", "\n ", $code); + + return $code; + } + + private function generateSerializeImpl(ClassMetadata $class): string + { + $reflector = $class->getReflectionClass(); + $properties = $reflector->hasMethod('__serialize') ? 'parent::__serialize()' : '(array) $this'; + + $code = '$properties = ' . $properties . '; + unset($properties["\0" . self::class . "\0lazyObjectId"], $properties[\'__isCloning\']); + + '; + + if ($reflector->hasMethod('__serialize') || ! $reflector->hasMethod('__sleep')) { + return $code . 'return $properties;'; + } + + return $code . '$data = []; + + foreach (parent::__sleep() as $name) { + $value = $properties[$k = $name] ?? $properties[$k = "\0*\0$name"] ?? $properties[$k = "\0' . $reflector->getName() . '\0$name"] ?? $k = null; + + if (null === $k) { + trigger_error(sprintf(\'serialize(): "%s" returned as member variable from __sleep() but does not exist\', $name), \E_USER_NOTICE); + } else { + $data[$k] = $value; + } + } + + return $data;'; + } } diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index 1d8e992a3ec..f1fa7d6c564 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -9,7 +9,6 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\EventManager; -use Doctrine\Common\Proxy\Proxy as CommonProxy; use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection; use Doctrine\DBAL\LockMode; use Doctrine\Deprecations\Deprecation; @@ -699,7 +698,7 @@ public function computeChangeSet(ClassMetadata $class, $entity) $value->setOwner($entity, $assoc); $value->setDirty(! $value->isEmpty()); - $class->reflFields[$name]->setValue($entity, $value); + $refProp->setValue($entity, $value); $actualData[$name] = $value; @@ -2373,6 +2372,11 @@ static function ($assoc) { */ private function cascadePersist($entity, array &$visited): void { + if ($entity instanceof Proxy && ! $entity->__isInitialized()) { + // nothing to do - proxy is not initialized, therefore we don't do anything with it + return; + } + $class = $this->em->getClassMetadata(get_class($entity)); $associationMappings = array_filter( @@ -2698,26 +2702,14 @@ public function createEntity($className, array $data, &$hints = []) && $unmanagedProxy instanceof Proxy && $this->isIdentifierEquals($unmanagedProxy, $entity) ) { - // DDC-1238 - we have a managed instance, but it isn't the provided one. - // Therefore we clear its identifier. Also, we must re-fetch metadata since the - // refreshed object may be anything - - foreach ($class->identifier as $fieldName) { - $class->reflFields[$fieldName]->setValue($unmanagedProxy, null); - } - - return $unmanagedProxy; + // We will hydrate the given un-managed proxy anyway: + // continue work, but consider it the entity from now on + $entity = $unmanagedProxy; } } if ($entity instanceof Proxy && ! $entity->__isInitialized()) { - if ($entity instanceof CommonProxy) { - $entity->__setInitialized(true); - } - - if ($entity instanceof NotifyPropertyChanged) { - $entity->addPropertyChangedListener($this); - } + $entity->__setInitialized(true); } else { if ( ! isset($hints[Query::HINT_REFRESH]) @@ -2743,15 +2735,15 @@ public function createEntity($className, array $data, &$hints = []) $this->identityMap[$class->rootEntityName][$idHash] = $entity; - if ($entity instanceof NotifyPropertyChanged) { - $entity->addPropertyChangedListener($this); - } - if (isset($hints[Query::HINT_READ_ONLY])) { $this->readOnlyObjects[$oid] = true; } } + if ($entity instanceof NotifyPropertyChanged) { + $entity->addPropertyChangedListener($this); + } + foreach ($data as $field => $value) { if (isset($class->fieldMappings[$field])) { $class->reflFields[$field]->setValue($entity, $value); @@ -2879,23 +2871,25 @@ public function createEntity($className, array $data, &$hints = []) break; default: + $normalizedAssociatedId = $this->normalizeIdentifier($targetClass, $associatedId); + switch (true) { // We are negating the condition here. Other cases will assume it is valid! case $hints['fetchMode'][$class->name][$field] !== ClassMetadata::FETCH_EAGER: - $newValue = $this->em->getProxyFactory()->getProxy($assoc['targetEntity'], $associatedId); + $newValue = $this->em->getProxyFactory()->getProxy($assoc['targetEntity'], $normalizedAssociatedId); break; // Deferred eager load only works for single identifier classes case isset($hints[self::HINT_DEFEREAGERLOAD]) && ! $targetClass->isIdentifierComposite: // TODO: Is there a faster approach? - $this->eagerLoadingEntities[$targetClass->rootEntityName][$relatedIdHash] = current($associatedId); + $this->eagerLoadingEntities[$targetClass->rootEntityName][$relatedIdHash] = current($normalizedAssociatedId); - $newValue = $this->em->getProxyFactory()->getProxy($assoc['targetEntity'], $associatedId); + $newValue = $this->em->getProxyFactory()->getProxy($assoc['targetEntity'], $normalizedAssociatedId); break; default: // TODO: This is very imperformant, ignore it? - $newValue = $this->em->find($assoc['targetEntity'], $associatedId); + $newValue = $this->em->find($assoc['targetEntity'], $normalizedAssociatedId); break; } @@ -3691,4 +3685,42 @@ private function convertSingleFieldIdentifierToPHPValue(ClassMetadata $class, $i $class->getTypeOfField($class->getSingleIdentifierFieldName()) ); } + + /** + * Given a flat identifier, this method will produce another flat identifier, but with all + * association fields that are mapped as identifiers replaced by entity references, recursively. + * + * @param mixed[] $flatIdentifier + * + * @return array + */ + private function normalizeIdentifier(ClassMetadata $targetClass, array $flatIdentifier): array + { + $normalizedAssociatedId = []; + + foreach ($targetClass->getIdentifierFieldNames() as $name) { + if (! array_key_exists($name, $flatIdentifier)) { + continue; + } + + if (! $targetClass->isSingleValuedAssociation($name)) { + $normalizedAssociatedId[$name] = $flatIdentifier[$name]; + continue; + } + + $targetIdMetadata = $this->em->getClassMetadata($targetClass->getAssociationTargetClass($name)); + + // Note: the ORM prevents using an entity with a composite identifier as an identifier association + // therefore, reset($targetIdMetadata->identifier) is always correct + $normalizedAssociatedId[$name] = $this->em->getReference( + $targetIdMetadata->getName(), + $this->normalizeIdentifier( + $targetIdMetadata, + [(string) reset($targetIdMetadata->identifier) => $flatIdentifier[$name]] + ) + ); + } + + return $normalizedAssociatedId; + } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist index f3ea3826e1f..45cb762abbc 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -242,6 +242,8 @@ tests/Doctrine/Tests/OrmFunctionalTestCase.php + + lib/Doctrine/ORM/Proxy/ProxyFactory.php tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6e070e334b7..16ff9312364 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -280,6 +280,11 @@ parameters: count: 1 path: lib/Doctrine/ORM/Persisters/Entity/CachedPersisterContext.php + - + message: "#^Access to an undefined property Doctrine\\\\Persistence\\\\Proxy::\\$__isCloning\\.$#" + count: 1 + path: lib/Doctrine/ORM/Proxy/ProxyFactory.php + - message: "#^Access to an undefined property Doctrine\\\\Persistence\\\\Mapping\\\\ClassMetadata\\:\\:\\$isEmbeddedClass\\.$#" count: 1 @@ -297,7 +302,7 @@ parameters: - message: "#^Parameter \\#1 \\$class of method Doctrine\\\\ORM\\\\Utility\\\\IdentifierFlattener\\:\\:flattenIdentifier\\(\\) expects Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata, Doctrine\\\\Persistence\\\\Mapping\\\\ClassMetadata given\\.$#" - count: 2 + count: 3 path: lib/Doctrine/ORM/Proxy/ProxyFactory.php - diff --git a/phpstan-persistence2.neon b/phpstan-persistence2.neon index f2e12c625d7..c00044c4f85 100644 --- a/phpstan-persistence2.neon +++ b/phpstan-persistence2.neon @@ -37,6 +37,11 @@ parameters: count: 1 path: lib/Doctrine/ORM/Tools/Console/Command/ClearCache/ResultCommand.php + - + message: '/^Call to an undefined method Doctrine\\Persistence\\Proxy::__setInitialized\(\)\.$/' + count: 1 + path: lib/Doctrine/ORM/UnitOfWork.php + # Symfony cache supports passing a key prefix to the clear method. - '/^Method Psr\\Cache\\CacheItemPoolInterface\:\:clear\(\) invoked with 1 parameter, 0 required\.$/' diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 2d223fadb96..fed5f27ff75 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1349,7 +1349,7 @@ - + $classMetadata $classMetadata @@ -1358,14 +1358,15 @@ $em->getMetadataFactory() $em->getMetadataFactory() - + $metadata->isEmbeddedClass $metadata->isMappedSuperclass + $proxy->__isCloning - + $property->name - + setAccessible diff --git a/tests/Doctrine/Performance/EntityManagerFactory.php b/tests/Doctrine/Performance/EntityManagerFactory.php index b3e023e661f..b13bf175c95 100644 --- a/tests/Doctrine/Performance/EntityManagerFactory.php +++ b/tests/Doctrine/Performance/EntityManagerFactory.php @@ -16,10 +16,14 @@ use Doctrine\ORM\ORMSetup; use Doctrine\ORM\Proxy\ProxyFactory; use Doctrine\ORM\Tools\SchemaTool; +use Doctrine\Persistence\Reflection\RuntimeReflectionProperty; use Doctrine\Tests\Mocks\DriverResultMock; +use Symfony\Component\VarExporter\LazyGhostTrait; use function array_map; +use function class_exists; use function realpath; +use function trait_exists; final class EntityManagerFactory { @@ -27,6 +31,7 @@ public static function getEntityManager(array $schemaClassNames): EntityManagerI { $config = new Configuration(); + $config->setLazyGhostObjectEnabled(trait_exists(LazyGhostTrait::class) && class_exists(RuntimeReflectionProperty::class)); $config->setProxyDir(__DIR__ . '/../Tests/Proxies'); $config->setProxyNamespace('Doctrine\Tests\Proxies'); $config->setAutoGenerateProxyClasses(ProxyFactory::AUTOGENERATE_EVAL); @@ -53,6 +58,7 @@ public static function makeEntityManagerWithNoResultsConnection(): EntityManager { $config = new Configuration(); + $config->setLazyGhostObjectEnabled(trait_exists(LazyGhostTrait::class) && class_exists(RuntimeReflectionProperty::class)); $config->setProxyDir(__DIR__ . '/../Tests/Proxies'); $config->setProxyNamespace('Doctrine\Tests\Proxies'); $config->setAutoGenerateProxyClasses(ProxyFactory::AUTOGENERATE_EVAL); diff --git a/tests/Doctrine/Tests/Mocks/EntityManagerMock.php b/tests/Doctrine/Tests/Mocks/EntityManagerMock.php index 8c3e8356904..3324c647ac7 100644 --- a/tests/Doctrine/Tests/Mocks/EntityManagerMock.php +++ b/tests/Doctrine/Tests/Mocks/EntityManagerMock.php @@ -12,8 +12,12 @@ use Doctrine\ORM\ORMSetup; use Doctrine\ORM\Proxy\ProxyFactory; use Doctrine\ORM\UnitOfWork; +use Doctrine\Persistence\Reflection\RuntimeReflectionProperty; +use Symfony\Component\VarExporter\LazyGhostTrait; +use function class_exists; use function sprintf; +use function trait_exists; /** * Special EntityManager mock used for testing purposes. @@ -30,6 +34,7 @@ public function __construct(Connection $conn, ?Configuration $config = null, ?Ev { if ($config === null) { $config = new Configuration(); + $config->setLazyGhostObjectEnabled(trait_exists(LazyGhostTrait::class) && class_exists(RuntimeReflectionProperty::class)); $config->setProxyDir(__DIR__ . '/../Proxies'); $config->setProxyNamespace('Doctrine\Tests\Proxies'); $config->setMetadataDriverImpl(ORMSetup::createDefaultAnnotationDriver()); diff --git a/tests/Doctrine/Tests/ORM/Functional/Locking/LockAgentWorker.php b/tests/Doctrine/Tests/ORM/Functional/Locking/LockAgentWorker.php index 129f8d9cd72..61177191f5c 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Locking/LockAgentWorker.php +++ b/tests/Doctrine/Tests/ORM/Functional/Locking/LockAgentWorker.php @@ -10,14 +10,18 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\ORMSetup; +use Doctrine\Persistence\Reflection\RuntimeReflectionProperty; use GearmanWorker; use InvalidArgumentException; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\VarExporter\LazyGhostTrait; use function assert; +use function class_exists; use function is_array; use function microtime; use function sleep; +use function trait_exists; use function unserialize; class LockAgentWorker @@ -113,6 +117,7 @@ protected function processWorkload($job): array protected function createEntityManager(Connection $conn): EntityManagerInterface { $config = new Configuration(); + $config->setLazyGhostObjectEnabled(trait_exists(LazyGhostTrait::class) && class_exists(RuntimeReflectionProperty::class)); $config->setProxyDir(__DIR__ . '/../../../Proxies'); $config->setProxyNamespace('MyProject\Proxies'); $config->setAutoGenerateProxyClasses(true); diff --git a/tests/Doctrine/Tests/ORM/Functional/MergeProxiesTest.php b/tests/Doctrine/Tests/ORM/Functional/MergeProxiesTest.php index 5d66c1316a4..fdf7df2b766 100644 --- a/tests/Doctrine/Tests/ORM/Functional/MergeProxiesTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/MergeProxiesTest.php @@ -12,14 +12,18 @@ use Doctrine\ORM\ORMSetup; use Doctrine\ORM\Tools\SchemaTool; use Doctrine\Persistence\Proxy; +use Doctrine\Persistence\Reflection\RuntimeReflectionProperty; use Doctrine\Tests\DbalExtensions\Connection; use Doctrine\Tests\DbalExtensions\QueryLog; use Doctrine\Tests\Models\Generic\DateTimeModel; use Doctrine\Tests\OrmFunctionalTestCase; +use Symfony\Component\VarExporter\LazyGhostTrait; use function assert; +use function class_exists; use function realpath; use function serialize; +use function trait_exists; use function unserialize; class MergeProxiesTest extends OrmFunctionalTestCase @@ -239,6 +243,7 @@ private function createEntityManager(): EntityManagerInterface { $config = new Configuration(); + $config->setLazyGhostObjectEnabled(trait_exists(LazyGhostTrait::class) && class_exists(RuntimeReflectionProperty::class)); $config->setProxyDir(realpath(__DIR__ . '/../../Proxies')); $config->setProxyNamespace('Doctrine\Tests\Proxies'); $config->setMetadataDriverImpl(ORMSetup::createDefaultAnnotationDriver( diff --git a/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php b/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php index 74ea162fbce..4ca665930fd 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php @@ -57,11 +57,11 @@ protected function setUp(): void public function testPersistUpdate(): void { // Considering case (a) - $proxy = $this->_em->getProxyFactory()->getProxy(CmsUser::class, ['id' => 123]); - $proxy->__isInitialized__ = true; - $proxy->id = null; - $proxy->username = 'ocra'; - $proxy->name = 'Marco'; + $proxy = $this->_em->getProxyFactory()->getProxy(CmsUser::class, ['id' => 123]); + $proxy->__setInitialized(true); + $proxy->id = null; + $proxy->username = 'ocra'; + $proxy->name = 'Marco'; $this->_em->persist($proxy); $this->_em->flush(); self::assertNotNull($proxy->getId()); diff --git a/tests/Doctrine/Tests/ORM/Functional/ReferenceProxyTest.php b/tests/Doctrine/Tests/ORM/Functional/ReferenceProxyTest.php index 10ef8561f2a..0793f4a42fb 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ReferenceProxyTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ReferenceProxyTest.php @@ -4,6 +4,7 @@ namespace Doctrine\Tests\ORM\Functional; +use Doctrine\Common\Proxy\Proxy as CommonProxy; use Doctrine\Common\Util\ClassUtils; use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\Company\CompanyAuction; @@ -140,7 +141,7 @@ public function testInitializeChangeAndFlushProxy(): void } /** @group DDC-1022 */ - public function testWakeupCalledOnProxy(): void + public function testWakeupOnProxy(): void { $id = $this->createProduct(); @@ -151,7 +152,11 @@ public function testWakeupCalledOnProxy(): void $entity->setName('Doctrine 2 Cookbook'); - self::assertTrue($entity->wakeUp, 'Loading the proxy should call __wakeup().'); + if ($entity instanceof CommonProxy) { + self::assertTrue($entity->wakeUp, 'Loading the proxy should call __wakeup().'); + } else { + self::assertFalse($entity->wakeUp, 'Loading the proxy should call __wakeup().'); + } } public function testDoNotInitializeProxyOnGettingTheIdentifier(): void diff --git a/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheManyToOneTest.php b/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheManyToOneTest.php index cd2e2527891..e06f1a5f4d3 100644 --- a/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheManyToOneTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheManyToOneTest.php @@ -4,6 +4,7 @@ namespace Doctrine\Tests\ORM\Functional; +use Doctrine\Common\Proxy\Proxy as CommonProxy; use Doctrine\ORM\Cache\Region; use Doctrine\Tests\Models\Cache\Action; use Doctrine\Tests\Models\Cache\City; @@ -251,11 +252,12 @@ public function testPutAndLoadNonCacheableCompositeManyToOne(): void self::assertInstanceOf(Action::class, $entity->getComplexAction()->getAction1()); self::assertInstanceOf(Action::class, $entity->getComplexAction()->getAction2()); - $this->assertQueryCount(1); + $expectedQueryCount = $entity->getAction() instanceof CommonProxy ? 1 : 0; + $this->assertQueryCount($expectedQueryCount); self::assertEquals('login', $entity->getComplexAction()->getAction1()->name); - $this->assertQueryCount(1); + $this->assertQueryCount($expectedQueryCount); self::assertEquals('rememberme', $entity->getComplexAction()->getAction2()->name); - $this->assertQueryCount(1); + $this->assertQueryCount($expectedQueryCount); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1238Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1238Test.php index 0a7dfdceead..c928a0403a5 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1238Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1238Test.php @@ -47,8 +47,6 @@ public function testIssueProxyClear(): void $this->_em->flush(); $this->_em->clear(); - // force proxy load, getId() doesn't work anymore - $user->getName(); $userId = $user->getId(); $this->_em->clear(); @@ -57,9 +55,13 @@ public function testIssueProxyClear(): void $user2 = $this->_em->getReference(DDC1238User::class, $userId); - // force proxy load, getId() doesn't work anymore - $user->getName(); - self::assertNull($user->getId(), 'Now this is null, we already have a user instance of that type'); + $user->__load(); + + self::assertIsInt($user->getId(), 'Even if a proxy is detached, it should still have an identifier'); + + $user2->__load(); + + self::assertIsInt($user2->getId(), 'The managed instance still has an identifier'); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/ValueObjectsTest.php b/tests/Doctrine/Tests/ORM/Functional/ValueObjectsTest.php index ea3682f1d97..777513db628 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ValueObjectsTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ValueObjectsTest.php @@ -20,6 +20,7 @@ use Doctrine\ORM\Mapping\ReflectionEmbeddedProperty; use Doctrine\ORM\Query\QueryException; use Doctrine\Persistence\Reflection\RuntimePublicReflectionProperty; +use Doctrine\Persistence\Reflection\RuntimeReflectionProperty; use Doctrine\Tests\OrmFunctionalTestCase; use function class_exists; @@ -51,6 +52,11 @@ public function testMetadataHasReflectionEmbeddablesAccessible(): void CommonRuntimePublicReflectionProperty::class, $classMetadata->getReflectionProperty('address') ); + } elseif (class_exists(RuntimeReflectionProperty::class)) { + self::assertInstanceOf( + RuntimeReflectionProperty::class, + $classMetadata->getReflectionProperty('address') + ); } else { self::assertInstanceOf( RuntimePublicReflectionProperty::class, diff --git a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataFactoryTest.php b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataFactoryTest.php index a2b74bdbf5c..652c6772db3 100644 --- a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataFactoryTest.php +++ b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataFactoryTest.php @@ -29,6 +29,7 @@ use Doctrine\ORM\Mapping\MappingException; use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Doctrine\Persistence\Mapping\RuntimeReflectionService; +use Doctrine\Persistence\Reflection\RuntimeReflectionProperty; use Doctrine\Tests\Mocks\EntityManagerMock; use Doctrine\Tests\Mocks\MetadataDriverMock; use Doctrine\Tests\Models\CMS\CmsArticle; @@ -44,12 +45,14 @@ use InvalidArgumentException; use PHPUnit\Framework\Assert; use ReflectionClass; +use Symfony\Component\VarExporter\LazyGhostTrait; use function array_search; use function assert; use function class_exists; use function count; use function sprintf; +use function trait_exists; class ClassMetadataFactoryTest extends OrmTestCase { @@ -283,6 +286,7 @@ public function testGetAllMetadataWorksWithBadConnection(): void protected function createEntityManager(MappingDriver $metadataDriver, $conn = null): EntityManagerMock { $config = new Configuration(); + $config->setLazyGhostObjectEnabled(trait_exists(LazyGhostTrait::class) && class_exists(RuntimeReflectionProperty::class)); $config->setProxyDir(__DIR__ . '/../../Proxies'); $config->setProxyNamespace('Doctrine\Tests\Proxies'); $eventManager = new EventManager(); diff --git a/tests/Doctrine/Tests/ORM/Proxy/ProxyFactoryTest.php b/tests/Doctrine/Tests/ORM/Proxy/ProxyFactoryTest.php index 46c1350552f..8342058a701 100644 --- a/tests/Doctrine/Tests/ORM/Proxy/ProxyFactoryTest.php +++ b/tests/Doctrine/Tests/ORM/Proxy/ProxyFactoryTest.php @@ -4,8 +4,8 @@ namespace Doctrine\Tests\ORM\Proxy; -use Closure; use Doctrine\Common\EventManager; +use Doctrine\Common\Proxy\Proxy as CommonProxy; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\ORM\EntityNotFoundException; @@ -77,7 +77,7 @@ public function testReferenceProxyDelegatesLoadingToThePersister(): void ->expects(self::atLeastOnce()) ->method('load') ->with(self::equalTo($identifier), self::isInstanceOf($proxyClass)) - ->will(self::returnValue(new stdClass())); + ->will(self::returnValue($proxy)); $proxy->getDescription(); } @@ -123,7 +123,7 @@ public function testSkipAbstractClassesOnGeneration(): void public function testFailedProxyLoadingDoesNotMarkTheProxyAsInitialized(): void { $persister = $this - ->getMockBuilderWithOnlyMethods(BasicEntityPersister::class, ['load', 'getClassMetadata']) + ->getMockBuilderWithOnlyMethods(BasicEntityPersister::class, ['load']) ->disableOriginalConstructor() ->getMock(); $this->uowMock->setEntityPersister(ECommerceFeature::class, $persister); @@ -143,15 +143,13 @@ public function testFailedProxyLoadingDoesNotMarkTheProxyAsInitialized(): void } self::assertFalse($proxy->__isInitialized()); - self::assertInstanceOf(Closure::class, $proxy->__getInitializer(), 'The initializer wasn\'t removed'); - self::assertInstanceOf(Closure::class, $proxy->__getCloner(), 'The cloner wasn\'t removed'); } /** @group DDC-2432 */ public function testFailedProxyCloningDoesNotMarkTheProxyAsInitialized(): void { $persister = $this - ->getMockBuilderWithOnlyMethods(BasicEntityPersister::class, ['load', 'getClassMetadata']) + ->getMockBuilderWithOnlyMethods(BasicEntityPersister::class, ['load']) ->disableOriginalConstructor() ->getMock(); $this->uowMock->setEntityPersister(ECommerceFeature::class, $persister); @@ -166,13 +164,12 @@ public function testFailedProxyCloningDoesNotMarkTheProxyAsInitialized(): void try { $cloned = clone $proxy; + $cloned->__load(); self::fail('An exception was expected to be raised'); } catch (EntityNotFoundException $exception) { } self::assertFalse($proxy->__isInitialized()); - self::assertInstanceOf(Closure::class, $proxy->__getInitializer(), 'The initializer wasn\'t removed'); - self::assertInstanceOf(Closure::class, $proxy->__getCloner(), 'The cloner wasn\'t removed'); } public function testProxyClonesParentFields(): void @@ -190,7 +187,7 @@ public function testProxyClonesParentFields(): void $classMetaData = $this->emMock->getClassMetadata(CompanyEmployee::class); $persister = $this - ->getMockBuilderWithOnlyMethods(BasicEntityPersister::class, ['load', 'getClassMetadata']) + ->getMockBuilderWithOnlyMethods(BasicEntityPersister::class, ['loadById', 'getClassMetadata']) ->disableOriginalConstructor() ->getMock(); $this->uowMock->setEntityPersister(CompanyEmployee::class, $persister); @@ -198,15 +195,25 @@ public function testProxyClonesParentFields(): void $proxy = $this->proxyFactory->getProxy(CompanyEmployee::class, ['id' => 42]); assert($proxy instanceof Proxy); - $persister - ->expects(self::atLeastOnce()) - ->method('load') - ->willReturn($companyEmployee); - - $persister + $loadByIdMock = $persister ->expects(self::atLeastOnce()) - ->method('getClassMetadata') - ->willReturn($classMetaData); + ->method('loadById'); + + if ($proxy instanceof CommonProxy) { + $loadByIdMock->willReturn($companyEmployee); + + $persister + ->expects(self::atLeastOnce()) + ->method('getClassMetadata') + ->willReturn($classMetaData); + } else { + $loadByIdMock->willReturnCallback(static function (array $id, CompanyEmployee $companyEmployee) { + $companyEmployee->setSalary(1000); // A property on the CompanyEmployee + $companyEmployee->setName('Bob'); // A property on the parent class, CompanyPerson + + return $companyEmployee; + }); + } $cloned = clone $proxy; assert($cloned instanceof CompanyEmployee); diff --git a/tests/Doctrine/Tests/ORM/Tools/ConvertDoctrine1SchemaTest.php b/tests/Doctrine/Tests/ORM/Tools/ConvertDoctrine1SchemaTest.php index d89218f821a..603becba75c 100644 --- a/tests/Doctrine/Tests/ORM/Tools/ConvertDoctrine1SchemaTest.php +++ b/tests/Doctrine/Tests/ORM/Tools/ConvertDoctrine1SchemaTest.php @@ -13,13 +13,16 @@ use Doctrine\ORM\Tools\DisconnectedClassMetadataFactory; use Doctrine\ORM\Tools\Export\ClassMetadataExporter; use Doctrine\Persistence\Mapping\Driver\MappingDriver; +use Doctrine\Persistence\Reflection\RuntimeReflectionProperty; use Doctrine\Tests\Mocks\EntityManagerMock; use Doctrine\Tests\OrmTestCase; +use Symfony\Component\VarExporter\LazyGhostTrait; use function class_exists; use function count; use function file_exists; use function rmdir; +use function trait_exists; use function unlink; /** @@ -42,6 +45,7 @@ protected function createEntityManager(MappingDriver $metadataDriver): EntityMan ->willReturn(new EventManager()); $config = new Configuration(); + $config->setLazyGhostObjectEnabled(trait_exists(LazyGhostTrait::class) && class_exists(RuntimeReflectionProperty::class)); $config->setProxyDir(__DIR__ . '/../../Proxies'); $config->setProxyNamespace('Doctrine\Tests\Proxies'); $config->setMetadataDriverImpl($metadataDriver); diff --git a/tests/Doctrine/Tests/ORM/Tools/Export/ClassMetadataExporterTestCase.php b/tests/Doctrine/Tests/ORM/Tools/Export/ClassMetadataExporterTestCase.php index 6d27b3c47f9..b177fddec2c 100644 --- a/tests/Doctrine/Tests/ORM/Tools/Export/ClassMetadataExporterTestCase.php +++ b/tests/Doctrine/Tests/ORM/Tools/Export/ClassMetadataExporterTestCase.php @@ -20,10 +20,13 @@ use Doctrine\ORM\Tools\Export\ClassMetadataExporter; use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Doctrine\Persistence\Mapping\Driver\PHPDriver; +use Doctrine\Persistence\Reflection\RuntimeReflectionProperty; use Doctrine\Tests\Mocks\EntityManagerMock; use Doctrine\Tests\OrmTestCase; +use Symfony\Component\VarExporter\LazyGhostTrait; use Symfony\Component\Yaml\Parser; +use function class_exists; use function count; use function current; use function file_get_contents; @@ -35,6 +38,7 @@ use function rtrim; use function simplexml_load_file; use function str_replace; +use function trait_exists; use function unlink; /** @@ -62,6 +66,7 @@ protected function createEntityManager($metadataDriver): EntityManagerMock ->willReturn(new EventManager()); $config = new Configuration(); + $config->setLazyGhostObjectEnabled(trait_exists(LazyGhostTrait::class) && class_exists(RuntimeReflectionProperty::class)); $config->setProxyDir(__DIR__ . '/../../Proxies'); $config->setProxyNamespace('Doctrine\Tests\Proxies'); $config->setMetadataDriverImpl($metadataDriver); diff --git a/tests/Doctrine/Tests/OrmFunctionalTestCase.php b/tests/Doctrine/Tests/OrmFunctionalTestCase.php index f0bdef69d38..4e8fae3a878 100644 --- a/tests/Doctrine/Tests/OrmFunctionalTestCase.php +++ b/tests/Doctrine/Tests/OrmFunctionalTestCase.php @@ -26,6 +26,7 @@ use Doctrine\ORM\Tools\SchemaTool; use Doctrine\ORM\Tools\ToolsException; use Doctrine\Persistence\Mapping\Driver\MappingDriver; +use Doctrine\Persistence\Reflection\RuntimeReflectionProperty; use Doctrine\Tests\DbalExtensions\QueryLog; use Doctrine\Tests\DbalTypes\Rot13Type; use Doctrine\Tests\EventListener\CacheMetadataListener; @@ -36,6 +37,7 @@ use Psr\Cache\CacheItemPoolInterface; use RuntimeException; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\VarExporter\LazyGhostTrait; use Throwable; use function array_map; @@ -43,6 +45,7 @@ use function array_reverse; use function array_slice; use function assert; +use function class_exists; use function explode; use function get_debug_type; use function getenv; @@ -53,6 +56,7 @@ use function sprintf; use function str_contains; use function strtolower; +use function trait_exists; use function var_export; use const PHP_EOL; @@ -764,6 +768,7 @@ protected function getEntityManager( //FIXME: two different configs! $conn and the created entity manager have // different configs. $config = new Configuration(); + $config->setLazyGhostObjectEnabled(trait_exists(LazyGhostTrait::class) && class_exists(RuntimeReflectionProperty::class)); $config->setMetadataCache(self::$metadataCache); $config->setQueryCache(self::$queryCache); $config->setProxyDir(__DIR__ . '/Proxies'); diff --git a/tests/Doctrine/Tests/OrmTestCase.php b/tests/Doctrine/Tests/OrmTestCase.php index 8cc988c6931..72ac086fde5 100644 --- a/tests/Doctrine/Tests/OrmTestCase.php +++ b/tests/Doctrine/Tests/OrmTestCase.php @@ -14,11 +14,15 @@ use Doctrine\ORM\Configuration; use Doctrine\ORM\Mapping\Driver\AnnotationDriver; use Doctrine\ORM\ORMSetup; +use Doctrine\Persistence\Reflection\RuntimeReflectionProperty; use Doctrine\Tests\Mocks\EntityManagerMock; use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\VarExporter\LazyGhostTrait; +use function class_exists; use function realpath; +use function trait_exists; /** * Base testcase class for all ORM testcases. @@ -76,6 +80,7 @@ protected function getTestEntityManager(?Connection $connection = null): EntityM $config = new Configuration(); + $config->setLazyGhostObjectEnabled(trait_exists(LazyGhostTrait::class) && class_exists(RuntimeReflectionProperty::class)); $config->setMetadataCache($metadataCache); $config->setQueryCache(self::getSharedQueryCache()); $config->setProxyDir(__DIR__ . '/Proxies');