From b6669746b74be9b62df68d44fca86bf909848c0b Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Thu, 23 Mar 2023 08:01:15 +0000 Subject: [PATCH 01/72] Run workflows also for the `entity-level-commit-order` branch --- .github/workflows/coding-standards.yml | 1 + .github/workflows/continuous-integration.yml | 1 + .github/workflows/phpbench.yml | 1 + .github/workflows/static-analysis.yml | 1 + 4 files changed, 4 insertions(+) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 1ce1a90c1b0..38ed3b07886 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - "*.x" + - entity-level-commit-order paths: - .github/workflows/coding-standards.yml - bin/** diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 7d5f9543c54..c0a1f991aa8 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - "*.x" + - entity-level-commit-order paths: - .github/workflows/continuous-integration.yml - ci/** diff --git a/.github/workflows/phpbench.yml b/.github/workflows/phpbench.yml index 5fcc3f53edb..9b3f7ebd54c 100644 --- a/.github/workflows/phpbench.yml +++ b/.github/workflows/phpbench.yml @@ -5,6 +5,7 @@ on: pull_request: branches: - "*.x" + - entity-level-commit-order paths: - .github/workflows/phpbench.yml - composer.* diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 2e9263b7a57..2ec0424d488 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - "*.x" + - entity-level-commit-order paths: - .github/workflows/static-analysis.yml - composer.* From b42cf99402f5cd6a899fcbc69cc9072a084ceea5 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 24 Apr 2023 11:39:33 +0200 Subject: [PATCH 02/72] Prepare entity-level commit order computation in the UnitOfWork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the second chunk to break #10547 into smaller PRs suitable for reviewing. It prepares the `UnitOfWork` to work with a commit order computed on the entity level, as opposed to a class-based ordering as before. #### Background #10531 and #10532 show that it is not always possible to run `UnitOfWork::commit()` with a commit order given in terms of entity _classes_. Instead it is necessary to process entities in an order computed on the _object_ level. That may include * a particular order for multiple entities of the _same_ class * a particular order for entities of _different_ classes, possibly even going back and forth (entity `$a1` of class `A`, then `$b` of class `B`, then `$a2` of class `A` – revisiting that class). This PR is about preparing the `UnitOfWork` so that its methods will be able to perform inserts and deletions on that level of granularity. Subsequent PRs will deal with implementing that particular order computation. #### Suggested change Change the private `executeInserts` and `executeDeletions` methods so that they do not take a `ClassMetadata` identifying the class of entities that shall be processed, but pass them the list of entities directly. The lists of entities are built in two dedicated methods. That happens basically as previously, by iterating over `$this->entityInsertions` or `$this->entityDeletions` and filtering those by class. #### Potential (BC breaking?) changes that need review scrutiny * `\Doctrine\ORM\Persisters\Entity\EntityPersister::addInsert()` was previously called multiple times, before the insert would be performed by a call to `\Doctrine\ORM\Persisters\Entity\EntityPersister::executeInserts()`. With the changes here, this batching effectively no longer takes place. `executeInserts()` will always see one entity/insert only, and there may be multiple `executeInserts()` calls during a single `UoW::commit()` phase. * The caching in `\Doctrine\ORM\Cache\Persister\Entity\AbstractEntityPersister` previously would cache entities from the last executed insert batch only. Now it will collect entities across multiple batches. I don't know if that poses a problem. * Overhead in `\Doctrine\ORM\Persisters\Entity\BasicEntityPersister::executeInserts` is incurred multiple times; that may, however, only be about SQL statement preparation and might be salvageable. * The `postPersist` event previously was not dispatched before _all_ instances of a particular entity class had been written to the database. Now, it will be dispatched immediately after every single entity that has been inserted. --- .../Entity/AbstractEntityPersister.php | 9 +- lib/Doctrine/ORM/UnitOfWork.php | 180 +++++++++++------- psalm-baseline.xml | 4 - 3 files changed, 117 insertions(+), 76 deletions(-) diff --git a/lib/Doctrine/ORM/Cache/Persister/Entity/AbstractEntityPersister.php b/lib/Doctrine/ORM/Cache/Persister/Entity/AbstractEntityPersister.php index 2fbc63855b3..cfd625d009b 100644 --- a/lib/Doctrine/ORM/Cache/Persister/Entity/AbstractEntityPersister.php +++ b/lib/Doctrine/ORM/Cache/Persister/Entity/AbstractEntityPersister.php @@ -23,6 +23,7 @@ use Doctrine\ORM\Persisters\Entity\EntityPersister; use Doctrine\ORM\UnitOfWork; +use function array_merge; use function assert; use function serialize; use function sha1; @@ -314,7 +315,13 @@ public function getOwningTable($fieldName) */ public function executeInserts() { - $this->queuedCache['insert'] = $this->persister->getInserts(); + // The commit order/foreign key relationships may make it necessary that multiple calls to executeInsert() + // are performed, so collect all the new entities. + $newInserts = $this->persister->getInserts(); + + if ($newInserts) { + $this->queuedCache['insert'] = array_merge($this->queuedCache['insert'] ?? [], $newInserts); + } return $this->persister->executeInserts(); } diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index 1e949cab4ee..94331d9ba82 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -57,10 +57,10 @@ use function array_map; use function array_merge; use function array_pop; +use function array_reverse; use function array_sum; use function array_values; use function assert; -use function count; use function current; use function func_get_arg; use function func_num_args; @@ -426,32 +426,37 @@ public function commit($entity = null) } if ($this->entityInsertions) { - foreach ($commitOrder as $class) { - $this->executeInserts($class); - } + // Perform entity insertions first, so that all new entities have their rows in the database + // and can be referred to by foreign keys. The commit order only needs to take new entities + // into account (new entities referring to other new entities), since all other types (entities + // with updates or scheduled deletions) are currently not a problem, since they are already + // in the database. + $this->executeInserts($this->computeInsertExecutionOrder($commitOrder)); } if ($this->entityUpdates) { - foreach ($commitOrder as $class) { - $this->executeUpdates($class); - } + // Updates do not need to follow a particular order + $this->executeUpdates(); } // Extra updates that were requested by persisters. + // This may include foreign keys that could not be set when an entity was inserted, + // which may happen in the case of circular foreign key relationships. if ($this->extraUpdates) { $this->executeExtraUpdates(); } // Collection updates (deleteRows, updateRows, insertRows) + // No particular order is necessary, since all entities themselves are already + // in the database foreach ($this->collectionUpdates as $collectionToUpdate) { $this->getCollectionPersister($collectionToUpdate->getMapping())->update($collectionToUpdate); } - // Entity deletions come last and need to be in reverse commit order + // Entity deletions come last. Their order only needs to take care of other deletions + // (first delete entities depending upon others, before deleting depended-upon entities). if ($this->entityDeletions) { - for ($count = count($commitOrder), $i = $count - 1; $i >= 0 && $this->entityDeletions; --$i) { - $this->executeDeletions($commitOrder[$i]); - } + $this->executeDeletions($this->computeDeleteExecutionOrder($commitOrder)); } // Commit failed silently @@ -1114,64 +1119,52 @@ public function recomputeSingleEntityChangeSet(ClassMetadata $class, $entity) } /** - * Executes all entity insertions for entities of the specified type. + * Executes entity insertions in the given order + * + * @param list $entities */ - private function executeInserts(ClassMetadata $class): void + private function executeInserts(array $entities): void { - $entities = []; - $className = $class->name; - $persister = $this->getEntityPersister($className); - $invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postPersist); - - $insertionsForClass = []; - - foreach ($this->entityInsertions as $oid => $entity) { - if ($this->em->getClassMetadata(get_class($entity))->name !== $className) { - continue; - } - - $insertionsForClass[$oid] = $entity; + foreach ($entities as $entity) { + $oid = spl_object_id($entity); + $class = $this->em->getClassMetadata(get_class($entity)); + $persister = $this->getEntityPersister($class->name); + $invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postPersist); $persister->addInsert($entity); unset($this->entityInsertions[$oid]); - if ($invoke !== ListenersInvoker::INVOKE_NONE) { - $entities[] = $entity; - } - } - - $postInsertIds = $persister->executeInserts(); + $postInsertIds = $persister->executeInserts(); - if ($postInsertIds) { - // Persister returned post-insert IDs - foreach ($postInsertIds as $postInsertId) { - $idField = $class->getSingleIdentifierFieldName(); - $idValue = $this->convertSingleFieldIdentifierToPHPValue($class, $postInsertId['generatedId']); + if ($postInsertIds) { + // Persister returned post-insert IDs + foreach ($postInsertIds as $postInsertId) { + $idField = $class->getSingleIdentifierFieldName(); + $idValue = $this->convertSingleFieldIdentifierToPHPValue($class, $postInsertId['generatedId']); - $entity = $postInsertId['entity']; - $oid = spl_object_id($entity); + $entity = $postInsertId['entity']; + $oid = spl_object_id($entity); - $class->reflFields[$idField]->setValue($entity, $idValue); + $class->reflFields[$idField]->setValue($entity, $idValue); - $this->entityIdentifiers[$oid] = [$idField => $idValue]; - $this->entityStates[$oid] = self::STATE_MANAGED; - $this->originalEntityData[$oid][$idField] = $idValue; + $this->entityIdentifiers[$oid] = [$idField => $idValue]; + $this->entityStates[$oid] = self::STATE_MANAGED; + $this->originalEntityData[$oid][$idField] = $idValue; - $this->addToIdentityMap($entity); - } - } else { - foreach ($insertionsForClass as $oid => $entity) { + $this->addToIdentityMap($entity); + } + } else { if (! isset($this->entityIdentifiers[$oid])) { //entity was not added to identity map because some identifiers are foreign keys to new entities. //add it now $this->addToEntityIdentifiersAndEntityMap($class, $oid, $entity); } } - } - foreach ($entities as $entity) { - $this->listenersInvoker->invoke($class, Events::postPersist, $entity, new PostPersistEventArgs($entity, $this->em), $invoke); + if ($invoke !== ListenersInvoker::INVOKE_NONE) { + $this->listenersInvoker->invoke($class, Events::postPersist, $entity, new PostPersistEventArgs($entity, $this->em), $invoke); + } } } @@ -1209,19 +1202,15 @@ private function addToEntityIdentifiersAndEntityMap( } /** - * Executes all entity updates for entities of the specified type. + * Executes all entity updates */ - private function executeUpdates(ClassMetadata $class): void + private function executeUpdates(): void { - $className = $class->name; - $persister = $this->getEntityPersister($className); - $preUpdateInvoke = $this->listenersInvoker->getSubscribedSystems($class, Events::preUpdate); - $postUpdateInvoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postUpdate); - foreach ($this->entityUpdates as $oid => $entity) { - if ($this->em->getClassMetadata(get_class($entity))->name !== $className) { - continue; - } + $class = $this->em->getClassMetadata(get_class($entity)); + $persister = $this->getEntityPersister($class->name); + $preUpdateInvoke = $this->listenersInvoker->getSubscribedSystems($class, Events::preUpdate); + $postUpdateInvoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postUpdate); if ($preUpdateInvoke !== ListenersInvoker::INVOKE_NONE) { $this->listenersInvoker->invoke($class, Events::preUpdate, $entity, new PreUpdateEventArgs($entity, $this->em, $this->getEntityChangeSet($entity)), $preUpdateInvoke); @@ -1242,18 +1231,17 @@ private function executeUpdates(ClassMetadata $class): void } /** - * Executes all entity deletions for entities of the specified type. + * Executes all entity deletions + * + * @param list $entities */ - private function executeDeletions(ClassMetadata $class): void + private function executeDeletions(array $entities): void { - $className = $class->name; - $persister = $this->getEntityPersister($className); - $invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postRemove); - - foreach ($this->entityDeletions as $oid => $entity) { - if ($this->em->getClassMetadata(get_class($entity))->name !== $className) { - continue; - } + foreach ($entities as $entity) { + $oid = spl_object_id($entity); + $class = $this->em->getClassMetadata(get_class($entity)); + $persister = $this->getEntityPersister($class->name); + $invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postRemove); $persister->delete($entity); @@ -1277,6 +1265,50 @@ private function executeDeletions(ClassMetadata $class): void } } + /** + * @param list $commitOrder + * + * @return list + */ + private function computeInsertExecutionOrder(array $commitOrder): array + { + $result = []; + foreach ($commitOrder as $class) { + $className = $class->name; + foreach ($this->entityInsertions as $entity) { + if ($this->em->getClassMetadata(get_class($entity))->name !== $className) { + continue; + } + + $result[] = $entity; + } + } + + return $result; + } + + /** + * @param list $commitOrder + * + * @return list + */ + private function computeDeleteExecutionOrder(array $commitOrder): array + { + $result = []; + foreach (array_reverse($commitOrder) as $class) { + $className = $class->name; + foreach ($this->entityDeletions as $entity) { + if ($this->em->getClassMetadata(get_class($entity))->name !== $className) { + continue; + } + + $result[] = $entity; + } + } + + return $result; + } + /** * Gets the commit order. * @@ -1343,7 +1375,13 @@ private function getCommitOrder(): array } } - return $calc->sort(); + // Remove duplicate class entries + $result = []; + foreach ($calc->sort() as $classMetadata) { + $result[$classMetadata->name] = $classMetadata; + } + + return array_values($result); } /** diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 1c14701234a..584de36bc9f 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -3043,10 +3043,6 @@ nonCascadedNewDetectedEntities]]> - - = 0 && $this->entityDeletions]]> - entityDeletions]]> - is_array($entity) From ed3432794130065a0c6d05fdf061ee8acd8bae59 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 24 Apr 2023 13:56:59 +0200 Subject: [PATCH 03/72] More precisely state conditions for the postPersist event --- docs/en/reference/events.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/reference/events.rst b/docs/en/reference/events.rst index 97c3a6f339c..f91a947ec70 100644 --- a/docs/en/reference/events.rst +++ b/docs/en/reference/events.rst @@ -707,8 +707,8 @@ not directly mapped by Doctrine. ``UPDATE`` statement. - The ``postPersist`` event occurs for an entity after the entity has been made persistent. It will be invoked after the - database insert operations. Generated primary key values are - available in the postPersist event. + database insert operation for that entity. A generated primary key value for + the entity will be available in the postPersist event. - The ``postRemove`` event occurs for an entity after the entity has been deleted. It will be invoked after the database delete operations. It is not called for a DQL ``DELETE`` statement. From 9ac063d8797ef01f89b35116ca1d7ca7269c08b1 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 8 May 2023 13:30:07 +0200 Subject: [PATCH 04/72] Add a new topological sort implementation (#10592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the first chunk to break #10547 into smaller PRs suitable for reviewing. It adds a new topological sort implementation. #### Background Topological sort is an algorithm that sorts the vertices of a directed acyclic graph (DAG) in a linear order such that for every directed edge from vertex A to vertex B, vertex A comes before vertex B in the ordering. This ordering is called a topological order. Ultimately (beyond the scope of this PR), in the ORM we'll need this to find an order in which we can insert new entities into the database. When one entity needs to refer to another one by means of a foreign key, the referred-to entity must be inserted before the referring entity. Deleting entities is similar. A topological sorting can be obtained by running a depth first search (DFS) on the graph. The order in which the DFS finishes on the vertices is a topological order. The DFS is possible iif there are no cycles in the graph. When there are cycles, the DFS will find them. For more information about topological sorting, as well as a description of an DFS-based topological sorting algorithm, see https://en.wikipedia.org/wiki/Topological_sorting. #### Current situation There is a DFS-based topological sorting implemented in the `CommitOrderCalculator`. This implementation has two kinks: 1. It does not detect cycles When there is a cycle in the DAG that cannot be resolved, we need to know about it. Ultimately, this means we will not be able to insert entities into the database in any order that allows all foreign keys constraints to be satisfied. If you look at `CommitOrderCalculator`, you'll see that there is no code dealing with this situation. 2. It has an obscure concept of edge "weights" To me, it is not clear how those are supposed to work. The weights are related to whether a foreign key is nullable or not, but can (could) be arbitrary integers. An edge will be ignored if it has a higher (lower) weight than another, already processed edge... 🤷🏻? #### Suggested change In fact, when inserting entities into the database, we have two kinds of foreign key relationships: Those that are `nullable`, and those that are not. Non-nullable foreign keys are hard requirements: Referred-to entities must be inserted first, no matter what. These are "non-optional" edges in the dependency graph. Nullable foreign keys can be set to `NULL` when first inserting an entity, and then coming back and updating the foreign key value after the referred-to (related) entity has been inserted into the database. This is already implemented in `\Doctrine\ORM\UnitOfWork::scheduleExtraUpdate`, at the expense of performing one extra `UPDATE` query after all the `INSERT`s have been processed. These edges are "optional". When finding a cycle that consists of non-optional edges only, treat it as a failure. We won't be able to insert entities with a circular dependency when all foreign keys are non-NULLable. When a cycle contains at least one optional edge, we can use it to break the cycle: Use backtracking to go back to the point before traversing the last _optional_ edge. This omits the edge from the topological sort order, but will cost one extra UPDATE later on. To make the transition easier, the new implementation is provided as a separate class, which is marked as `@internal`. #### Outlook Backtracking to the last optional edge is the simplest solution for now. In general, it might be better to find _another_ (optional) edge that would also break the cycle, if that edge is also part of _other_ cycles. Remember, every optional edge skipped costs an extra UPDATE query later on. The underlying problem is known as the "minimum feedback arc set" problem, and is not easily/efficiently solvable. Thus, for the time being, picking the nearest optional edge seems to be reasonable. --- lib/Doctrine/ORM/Internal/TopologicalSort.php | 165 ++++++++++++ .../CycleDetectedException.php | 55 ++++ .../ORM/Internal/TopologicalSortTest.php | 248 ++++++++++++++++++ 3 files changed, 468 insertions(+) create mode 100644 lib/Doctrine/ORM/Internal/TopologicalSort.php create mode 100644 lib/Doctrine/ORM/Internal/TopologicalSort/CycleDetectedException.php create mode 100644 tests/Doctrine/Tests/ORM/Internal/TopologicalSortTest.php diff --git a/lib/Doctrine/ORM/Internal/TopologicalSort.php b/lib/Doctrine/ORM/Internal/TopologicalSort.php new file mode 100644 index 00000000000..35bc43c7269 --- /dev/null +++ b/lib/Doctrine/ORM/Internal/TopologicalSort.php @@ -0,0 +1,165 @@ + + */ + private $nodes = []; + + /** + * DFS state for the different nodes, indexed by node object id and using one of + * this class' constants as value. + * + * @var array + */ + private $states = []; + + /** + * Edges between the nodes. The first-level key is the object id of the outgoing + * node; the second array maps the destination node by object id as key. The final + * boolean value indicates whether the edge is optional or not. + * + * @var array> + */ + private $edges = []; + + /** + * Builds up the result during the DFS. + * + * @psalm-var list + */ + private $sortResult = []; + + /** @param object $node */ + public function addNode($node): void + { + $id = spl_object_id($node); + $this->nodes[$id] = $node; + $this->states[$id] = self::NOT_VISITED; + $this->edges[$id] = []; + } + + /** @param object $node */ + public function hasNode($node): bool + { + return isset($this->nodes[spl_object_id($node)]); + } + + /** + * Adds a new edge between two nodes to the graph + * + * @param object $from + * @param object $to + * @param bool $optional This indicates whether the edge may be ignored during the topological sort if it is necessary to break cycles. + */ + public function addEdge($from, $to, bool $optional): void + { + $fromId = spl_object_id($from); + $toId = spl_object_id($to); + + if (isset($this->edges[$fromId][$toId]) && $this->edges[$fromId][$toId] === false) { + return; // we already know about this dependency, and it is not optional + } + + $this->edges[$fromId][$toId] = $optional; + } + + /** + * Returns a topological sort of all nodes. When we have an edge A->B between two nodes + * A and B, then A will be listed before B in the result. + * + * @psalm-return list + */ + public function sort() + { + /* + * When possible, keep objects in the result in the same order in which they were added as nodes. + * Since nodes are unshifted into $this->>sortResult (see the visit() method), that means we + * need to work them in array_reverse order here. + */ + foreach (array_reverse(array_keys($this->nodes)) as $oid) { + if ($this->states[$oid] === self::NOT_VISITED) { + $this->visit($oid); + } + } + + return $this->sortResult; + } + + private function visit(int $oid): void + { + if ($this->states[$oid] === self::IN_PROGRESS) { + // This node is already on the current DFS stack. We've found a cycle! + throw new CycleDetectedException($this->nodes[$oid]); + } + + if ($this->states[$oid] === self::VISITED) { + // We've reached a node that we've already seen, including all + // other nodes that are reachable from here. We're done here, return. + return; + } + + $this->states[$oid] = self::IN_PROGRESS; + + // Continue the DFS downwards the edge list + foreach ($this->edges[$oid] as $adjacentId => $optional) { + try { + $this->visit($adjacentId); + } catch (CycleDetectedException $exception) { + if ($exception->isCycleCollected()) { + // There is a complete cycle downstream of the current node. We cannot + // do anything about that anymore. + throw $exception; + } + + if ($optional) { + // The current edge is part of a cycle, but it is optional and the closest + // such edge while backtracking. Break the cycle here by skipping the edge + // and continuing with the next one. + continue; + } + + // We have found a cycle and cannot break it at $edge. Best we can do + // is to retreat from the current vertex, hoping that somewhere up the + // stack this can be salvaged. + $this->states[$oid] = self::NOT_VISITED; + $exception->addToCycle($this->nodes[$oid]); + + throw $exception; + } + } + + // We have traversed all edges and visited all other nodes reachable from here. + // So we're done with this vertex as well. + + $this->states[$oid] = self::VISITED; + array_unshift($this->sortResult, $this->nodes[$oid]); + } +} diff --git a/lib/Doctrine/ORM/Internal/TopologicalSort/CycleDetectedException.php b/lib/Doctrine/ORM/Internal/TopologicalSort/CycleDetectedException.php new file mode 100644 index 00000000000..9b0bc49d257 --- /dev/null +++ b/lib/Doctrine/ORM/Internal/TopologicalSort/CycleDetectedException.php @@ -0,0 +1,55 @@ + */ + private $cycle; + + /** @var object */ + private $startNode; + + /** + * Do we have the complete cycle collected? + * + * @var bool + */ + private $cycleCollected = false; + + /** @param object $startNode */ + public function __construct($startNode) + { + parent::__construct('A cycle has been detected, so a topological sort is not possible. The getCycle() method provides the list of nodes that form the cycle.'); + + $this->startNode = $startNode; + $this->cycle = [$startNode]; + } + + /** @return list */ + public function getCycle(): array + { + return $this->cycle; + } + + /** @param object $node */ + public function addToCycle($node): void + { + array_unshift($this->cycle, $node); + + if ($node === $this->startNode) { + $this->cycleCollected = true; + } + } + + public function isCycleCollected(): bool + { + return $this->cycleCollected; + } +} diff --git a/tests/Doctrine/Tests/ORM/Internal/TopologicalSortTest.php b/tests/Doctrine/Tests/ORM/Internal/TopologicalSortTest.php new file mode 100644 index 00000000000..bba00d2d44e --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Internal/TopologicalSortTest.php @@ -0,0 +1,248 @@ + */ + private $nodes = []; + + /** @var TopologicalSort */ + private $topologicalSort; + + protected function setUp(): void + { + $this->topologicalSort = new TopologicalSort(); + } + + public function testSimpleOrdering(): void + { + $this->addNodes('C', 'B', 'A', 'E'); + + $this->addEdge('A', 'B'); + $this->addEdge('B', 'C'); + $this->addEdge('E', 'A'); + + // There is only 1 valid ordering for this constellation + self::assertSame(['E', 'A', 'B', 'C'], $this->computeResult()); + } + + public function testSkipOptionalEdgeToBreakCycle(): void + { + $this->addNodes('A', 'B'); + + $this->addEdge('A', 'B', true); + $this->addEdge('B', 'A', false); + + self::assertSame(['B', 'A'], $this->computeResult()); + } + + public function testBreakCycleByBacktracking(): void + { + $this->addNodes('A', 'B', 'C', 'D'); + + $this->addEdge('A', 'B'); + $this->addEdge('B', 'C', true); + $this->addEdge('C', 'D'); + $this->addEdge('D', 'A'); // closes the cycle + + // We can only break B -> C, so the result must be C -> D -> A -> B + self::assertSame(['C', 'D', 'A', 'B'], $this->computeResult()); + } + + public function testCycleRemovedByEliminatingLastOptionalEdge(): void + { + // The cycle-breaking algorithm is currently very naive. It breaks the cycle + // at the last optional edge while it backtracks. In this example, we might + // get away with one extra update if we'd break A->B; instead, we break up + // B->C and B->D. + + $this->addNodes('A', 'B', 'C', 'D'); + + $this->addEdge('A', 'B', true); + $this->addEdge('B', 'C', true); + $this->addEdge('C', 'A'); + $this->addEdge('B', 'D', true); + $this->addEdge('D', 'A'); + + self::assertSame(['C', 'D', 'A', 'B'], $this->computeResult()); + } + + public function testGH7180Example(): void + { + // Example given in https://github.com/doctrine/orm/pull/7180#issuecomment-381341943 + + $this->addNodes('E', 'F', 'D', 'G'); + + $this->addEdge('D', 'G'); + $this->addEdge('D', 'F', true); + $this->addEdge('F', 'E'); + $this->addEdge('E', 'D'); + + self::assertSame(['F', 'E', 'D', 'G'], $this->computeResult()); + } + + public function testCommitOrderingFromGH7259Test(): void + { + // this test corresponds to the GH7259Test::testPersistFileBeforeVersion functional test + $this->addNodes('A', 'B', 'C', 'D'); + + $this->addEdge('D', 'A'); + $this->addEdge('A', 'B'); + $this->addEdge('D', 'C'); + $this->addEdge('A', 'D', true); + + // There is only multiple valid ordering for this constellation, but + // the D -> A -> B ordering is important to break the cycle + // on the nullable link. + $correctOrders = [ + ['D', 'A', 'B', 'C'], + ['D', 'A', 'C', 'B'], + ['D', 'C', 'A', 'B'], + ]; + + self::assertContains($this->computeResult(), $correctOrders); + } + + public function testCommitOrderingFromGH8349Case1Test(): void + { + $this->addNodes('A', 'B', 'C', 'D'); + + $this->addEdge('D', 'A'); + $this->addEdge('A', 'B', true); + $this->addEdge('B', 'D', true); + $this->addEdge('B', 'C', true); + $this->addEdge('C', 'D', true); + + // Many orderings are possible here, but the bottom line is D must be before A (it's the only hard requirement). + $result = $this->computeResult(); + + $indexA = array_search('A', $result, true); + $indexD = array_search('D', $result, true); + self::assertTrue($indexD < $indexA); + } + + public function testCommitOrderingFromGH8349Case2Test(): void + { + $this->addNodes('A', 'B'); + + $this->addEdge('B', 'A'); + $this->addEdge('B', 'A', true); // interesting: We have two edges in that direction + $this->addEdge('A', 'B', true); + + // The B -> A requirement determines the result here + self::assertSame(['B', 'A'], $this->computeResult()); + } + + public function testNodesMaintainOrderWhenNoDepencency(): void + { + $this->addNodes('A', 'B', 'C'); + + // Nodes that are not constrained by dependencies shall maintain the order + // in which they were added + self::assertSame(['A', 'B', 'C'], $this->computeResult()); + } + + public function testDetectSmallCycle(): void + { + $this->addNodes('A', 'B'); + + $this->addEdge('A', 'B'); + $this->addEdge('B', 'A'); + + $this->expectException(CycleDetectedException::class); + $this->computeResult(); + } + + public function testMultipleEdges(): void + { + // There may be more than one association between two given entities. + // For the commit order, we only need to track this once, since the + // result is the same (one entity must be processed before the other). + // + // In case one of the associations is optional and the other one is not, + // we must honor the non-optional one, regardless of the order in which + // they were declared. + + $this->addNodes('A', 'B'); + + $this->addEdge('A', 'B', true); // optional comes first + $this->addEdge('A', 'B', false); + $this->addEdge('B', 'A', false); + $this->addEdge('B', 'A', true); // optional comes last + + // Both edges A -> B and B -> A are non-optional, so this is a cycle + // that cannot be broken. + + $this->expectException(CycleDetectedException::class); + $this->computeResult(); + } + + public function testDetectLargerCycleNotIncludingStartNode(): void + { + $this->addNodes('A', 'B', 'C', 'D'); + + $this->addEdge('A', 'B'); + $this->addEdge('B', 'C'); + $this->addEdge('C', 'D'); + $this->addEdge('D', 'B'); + + // The sort has to start with the last node being added to make it possible that + // the result is in the order the nodes were added (if permitted by edges). + // That means the cycle will be detected when starting at D, so it is D -> B -> C -> D. + + try { + $this->computeResult(); + } catch (CycleDetectedException $exception) { + self::assertEquals( + [$this->nodes['D'], $this->nodes['B'], $this->nodes['C'], $this->nodes['D']], + $exception->getCycle() + ); + } + } + + private function addNodes(string ...$names): void + { + foreach ($names as $name) { + $node = new Node($name); + $this->nodes[$name] = $node; + $this->topologicalSort->addNode($node); + } + } + + private function addEdge(string $from, string $to, bool $optional = false): void + { + $this->topologicalSort->addEdge($this->nodes[$from], $this->nodes[$to], $optional); + } + + /** + * @return list + */ + private function computeResult(): array + { + return array_map(static function (Node $n): string { + return $n->name; + }, array_values($this->topologicalSort->sort())); + } +} + +class Node +{ + /** @var string */ + public $name; + + public function __construct(string $name) + { + $this->name = $name; + } +} From ddd3066bc4fb6912c376cbd9b7483c3cd6e63da3 Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Thu, 25 May 2023 08:48:53 +0200 Subject: [PATCH 05/72] Revert "Allow symfony/console 7 (#10724)" This reverts commit 66621959365e20bba687b2cc7450aeac152a657b. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d38e3eed5da..f8a72fc661e 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "doctrine/lexer": "^2", "doctrine/persistence": "^2.4 || ^3", "psr/cache": "^1 || ^2 || ^3", - "symfony/console": "^4.2 || ^5.0 || ^6.0 || ^7.0", + "symfony/console": "^4.2 || ^5.0 || ^6.0", "symfony/polyfill-php72": "^1.23", "symfony/polyfill-php80": "^1.16" }, From ae60cf005fb62696404d8b2a8a9dc9c491c32ab9 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Tue, 30 May 2023 22:58:36 +0200 Subject: [PATCH 06/72] Commit order for removals has to consider `SET NULL`, not `nullable` (#10566) When computing the commit order for entity removals, we have to look out for `@ORM\JoinColumn(onDelete="SET NULL")` to find places where cyclic associations can be broken. #### Background The UoW computes a "commit order" to find the sequence in which tables shall be processed when inserting entities into the database or performing delete operations. For the insert case, the ORM is able to schedule _extra updates_ that will be performed after all entities have been inserted. Associations which are configured as `@ORM\JoinColumn(nullable=true, ...)` can be left as `NULL` in the database when performing the initial `INSERT` statements, and will be updated once all new entities have been written to the database. This can be used to break cyclic associations between entity instances. For removals, the ORM does not currently implement up-front `UPDATE` statements to `NULL` out associations before `DELETE` statements are executed. That means when associations form a cycle, users have to configure `@ORM\JoinColumn(onDelete="SET NULL", ...)` on one of the associations involved. This transfers responsibility to the DBMS to break the cycle at that place. _But_, we still have to perform the delete statements in an order that makes this happen early enough. This may be a _different_ order than the one required for the insert case. We can find it _only_ by looking at the `onDelete` behaviour. We must ignore the `nullable` property, which is irrelevant, since we do not even try to `NULL` anything. #### Example Assume three entity classes `A`, `B`, `C`. There are unidirectional one-to-one associations `A -> B`, `B -> C`, `C -> A`. All those associations are `nullable= true`. Three entities `$a`, `$b`, `$c` are created from these respective classes and associations are set up. All operations `cascade` at the ORM level. So we can test what happens when we start the operations at the three individual entities, but in the end, they will always involve all three of them. _Any_ insert order will work, so the improvements necessary to solve #10531 or #10532 are not needed here. Since all associations are between different tables, the current table-level computation is good enough. For the removal case, only the `A -> B` association has `onDelete="SET NULL"`. So, the only possible execution order is `$b`, `$c`, `$a`. We have to find that regardless of where we start the cascade operation. The DBMS will set the `A -> B` association on `$a` to `NULL` when we remove `$b`. We can then remove `$c` since it is no longer being referred to, then `$a`. #### Related cases These cases ask for the ORM to perform the extra update before the delete by itself, without DBMS-level support: * #5665 * #10548 --- lib/Doctrine/ORM/UnitOfWork.php | 79 +++++--- .../ORM/Functional/Ticket/GH10566Test.php | 177 ++++++++++++++++++ 2 files changed, 230 insertions(+), 26 deletions(-) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH10566Test.php diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index 94331d9ba82..d7f563c41ee 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -28,6 +28,7 @@ use Doctrine\ORM\Id\AssignedGenerator; use Doctrine\ORM\Internal\CommitOrderCalculator; use Doctrine\ORM\Internal\HydrationCompleteHandler; +use Doctrine\ORM\Internal\TopologicalSort; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\MappingException; use Doctrine\ORM\Mapping\Reflection\ReflectionPropertiesGetter; @@ -57,7 +58,6 @@ use function array_map; use function array_merge; use function array_pop; -use function array_reverse; use function array_sum; use function array_values; use function assert; @@ -74,6 +74,7 @@ use function reset; use function spl_object_id; use function sprintf; +use function strtolower; /** * The UnitOfWork is responsible for tracking changes to objects during an @@ -408,9 +409,6 @@ public function commit($entity = null) $this->dispatchOnFlushEvent(); - // Now we need a commit order to maintain referential integrity - $commitOrder = $this->getCommitOrder(); - $conn = $this->em->getConnection(); $conn->beginTransaction(); @@ -431,7 +429,7 @@ public function commit($entity = null) // into account (new entities referring to other new entities), since all other types (entities // with updates or scheduled deletions) are currently not a problem, since they are already // in the database. - $this->executeInserts($this->computeInsertExecutionOrder($commitOrder)); + $this->executeInserts($this->computeInsertExecutionOrder()); } if ($this->entityUpdates) { @@ -456,7 +454,7 @@ public function commit($entity = null) // Entity deletions come last. Their order only needs to take care of other deletions // (first delete entities depending upon others, before deleting depended-upon entities). if ($this->entityDeletions) { - $this->executeDeletions($this->computeDeleteExecutionOrder($commitOrder)); + $this->executeDeletions($this->computeDeleteExecutionOrder()); } // Commit failed silently @@ -1265,14 +1263,11 @@ private function executeDeletions(array $entities): void } } - /** - * @param list $commitOrder - * - * @return list - */ - private function computeInsertExecutionOrder(array $commitOrder): array + /** @return list */ + private function computeInsertExecutionOrder(): array { - $result = []; + $commitOrder = $this->getCommitOrder(); + $result = []; foreach ($commitOrder as $class) { $className = $class->name; foreach ($this->entityInsertions as $entity) { @@ -1287,26 +1282,58 @@ private function computeInsertExecutionOrder(array $commitOrder): array return $result; } - /** - * @param list $commitOrder - * - * @return list - */ - private function computeDeleteExecutionOrder(array $commitOrder): array + /** @return list */ + private function computeDeleteExecutionOrder(): array { - $result = []; - foreach (array_reverse($commitOrder) as $class) { - $className = $class->name; - foreach ($this->entityDeletions as $entity) { - if ($this->em->getClassMetadata(get_class($entity))->name !== $className) { + $sort = new TopologicalSort(); + + // First make sure we have all the nodes + foreach ($this->entityDeletions as $entity) { + $sort->addNode($entity); + } + + // Now add edges + foreach ($this->entityDeletions as $entity) { + $class = $this->em->getClassMetadata(get_class($entity)); + + foreach ($class->associationMappings as $assoc) { + // We only need to consider the owning sides of to-one associations, + // since many-to-many associations can always be (and have already been) + // deleted in a preceding step. + if (! ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE)) { continue; } - $result[] = $entity; + // For associations that implement a database-level cascade/set null operation, + // we do not have to follow a particular order: If the referred-to entity is + // deleted first, the DBMS will either delete the current $entity right away + // (CASCADE) or temporarily set the foreign key to NULL (SET NULL). + // Either way, we can skip it in the computation. + assert(isset($assoc['joinColumns'])); + $joinColumns = reset($assoc['joinColumns']); + if (isset($joinColumns['onDelete'])) { + $onDeleteOption = strtolower($joinColumns['onDelete']); + if ($onDeleteOption === 'cascade' || $onDeleteOption === 'set null') { + continue; + } + } + + $targetEntity = $class->getFieldValue($entity, $assoc['fieldName']); + + // If the association does not refer to another entity or that entity + // is not to be deleted, there is no ordering problem and we can + // skip this particular association. + if ($targetEntity === null || ! $sort->hasNode($targetEntity)) { + continue; + } + + // Add dependency. The dependency direction implies that "$entity has to be removed before $targetEntity", + // so we can work through the topo sort result from left to right (with all edges pointing right). + $sort->addEdge($entity, $targetEntity, false); } } - return $result; + return $sort->sort(); } /** diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10566Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10566Test.php new file mode 100644 index 00000000000..91b6174a560 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10566Test.php @@ -0,0 +1,177 @@ +createSchemaForModels( + GH10566A::class, + GH10566B::class, + GH10566C::class + ); + } + + /** + * @dataProvider provideEntityClasses + */ + public function testInsertion(string $startEntityClass): void + { + $a = new GH10566A(); + $b = new GH10566B(); + $c = new GH10566C(); + + $a->other = $b; + $b->other = $c; + $c->other = $a; + + foreach ([$a, $b, $c] as $candidate) { + if (is_a($candidate, $startEntityClass)) { + $this->_em->persist($candidate); + } + } + + // Since all associations are nullable, the ORM has no problem finding an insert order, + // it can always schedule "deferred updates" to fill missing foreign key values. + $this->_em->flush(); + + self::assertNotNull($a->id); + self::assertNotNull($b->id); + self::assertNotNull($c->id); + } + + /** + * @dataProvider provideEntityClasses + */ + public function testRemoval(string $startEntityClass): void + { + $a = new GH10566A(); + $b = new GH10566B(); + $c = new GH10566C(); + + $a->other = $b; + $b->other = $c; + $c->other = $a; + + $this->_em->persist($a); + $this->_em->flush(); + + $aId = $a->id; + $bId = $b->id; + $cId = $c->id; + + // In the removal case, the ORM currently does not schedule "extra updates" + // to break association cycles before entities are removed. So, we must not + // look at "nullable" for associations to find a delete commit order. + // + // To make it work, the user needs to have a database-level "ON DELETE SET NULL" + // on an association. That's where the cycle can be broken. Commit order computation + // for the removal case needs to look at this property. + // + // In this example, only A -> B can be used to break the cycle. So, regardless which + // entity we start with, the ORM-level cascade will always remove all three entities, + // and the order of database deletes always has to be (can only be) from B, then C, then A. + + foreach ([$a, $b, $c] as $candidate) { + if (is_a($candidate, $startEntityClass)) { + $this->_em->remove($candidate); + } + } + + $this->_em->flush(); + + self::assertFalse($this->_em->getConnection()->fetchOne('SELECT id FROM gh10566_a WHERE id = ?', [$aId])); + self::assertFalse($this->_em->getConnection()->fetchOne('SELECT id FROM gh10566_b WHERE id = ?', [$bId])); + self::assertFalse($this->_em->getConnection()->fetchOne('SELECT id FROM gh10566_c WHERE id = ?', [$cId])); + } + + public function provideEntityClasses(): Generator + { + yield [GH10566A::class]; + yield [GH10566B::class]; + yield [GH10566C::class]; + } +} + +/** + * @ORM\Entity + * @ORM\Table(name="gh10566_a") + */ +class GH10566A +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue() + * + * @var int + */ + public $id; + + /** + * @ORM\OneToOne(targetEntity="GH10566B", cascade={"all"}) + * @ORM\JoinColumn(nullable=true, onDelete="SET NULL") + * + * @var GH10566B + */ + public $other; +} + +/** + * @ORM\Entity + * @ORM\Table(name="gh10566_b") + */ +class GH10566B +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue() + * + * @var int + */ + public $id; + + /** + * @ORM\OneToOne(targetEntity="GH10566C", cascade={"all"}) + * @ORM\JoinColumn(nullable=true) + * + * @var GH10566C + */ + public $other; +} + +/** + * @ORM\Entity + * @ORM\Table(name="gh10566_c") + */ +class GH10566C +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue() + * + * @var int + */ + public $id; + + /** + * @ORM\OneToOne(targetEntity="GH10566A", cascade={"all"}) + * @ORM\JoinColumn(nullable=true) + * + * @var GH10566A + */ + public $other; +} From d76fc4ebf61ce09c1dd759fa22ee9f4f72d336cb Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 8 May 2023 16:02:12 +0000 Subject: [PATCH 07/72] Compute entity-level commit order for entity insertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the third step to break https://github.com/doctrine/orm/pull/10547 into smaller PRs suitable for reviewing. It uses the new topological sort implementation from #10592 and the refactoring from #10651 to compute the UoW's commit order for entity insertions not on the entity class level, but for single entities and their actual dependencies instead. #### Current situation `UnitOfWork::getCommitOrder()` would compute the entity sequence on the class level with the following code: https://github.com/doctrine/orm/blob/70477d81e96c0044ad6fd8c13c37b2270d082792/lib/Doctrine/ORM/UnitOfWork.php#L1310-L1325 #### Suggested change * Instead of considering the classes of all entities that need to be inserted, updated or deleted, consider the new (inserted) entities only. We only need to find a sequence in situations where there are foreign key relationships between two _new_ entities. * In the dependency graph, add edges for all to-one association target entities. * Make edges "optional" when the association is nullable. #### Test changes I have not tried to fully understand the few changes necessary to fix the tests. My guess is that those are edge cases where the insert order changed and we need to consider this during clean-up. Keep in mind that many of the functional tests we have assume that entities have IDs assigned in the order that they were added to the EntityManager. That does not change – so the order of entities is generally stable, equal to the previous implementation. --- lib/Doctrine/ORM/UnitOfWork.php | 47 +++++++++++++++---- ...OneToOneSelfReferentialAssociationTest.php | 6 ++- .../Doctrine/Tests/OrmFunctionalTestCase.php | 1 + 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index d7f563c41ee..83bf461dc39 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -1266,20 +1266,51 @@ private function executeDeletions(array $entities): void /** @return list */ private function computeInsertExecutionOrder(): array { - $commitOrder = $this->getCommitOrder(); - $result = []; - foreach ($commitOrder as $class) { - $className = $class->name; - foreach ($this->entityInsertions as $entity) { - if ($this->em->getClassMetadata(get_class($entity))->name !== $className) { + $sort = new TopologicalSort(); + + // First make sure we have all the nodes + foreach ($this->entityInsertions as $entity) { + $sort->addNode($entity); + } + + // Now add edges + foreach ($this->entityInsertions as $entity) { + $class = $this->em->getClassMetadata(get_class($entity)); + + foreach ($class->associationMappings as $assoc) { + // We only need to consider the owning sides of to-one associations, + // since many-to-many associations are persisted at a later step and + // have no insertion order problems (all entities already in the database + // at that time). + if (! ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE)) { + continue; + } + + $targetEntity = $class->getFieldValue($entity, $assoc['fieldName']); + + // If there is no entity that we need to refer to, or it is already in the + // database (i. e. does not have to be inserted), no need to consider it. + if ($targetEntity === null || ! $sort->hasNode($targetEntity)) { continue; } - $result[] = $entity; + // According to https://www.doctrine-project.org/projects/doctrine-orm/en/2.14/reference/annotations-reference.html#annref_joincolumn, + // the default for "nullable" is true. Unfortunately, it seems this default is not applied at the metadata driver, factory or other + // level, but in fact we may have an undefined 'nullable' key here, so we must assume that default here as well. + // + // Same in \Doctrine\ORM\Tools\EntityGenerator::isAssociationIsNullable or \Doctrine\ORM\Persisters\Entity\BasicEntityPersister::getJoinSQLForJoinColumns, + // to give two examples. + assert(isset($assoc['joinColumns'])); + $joinColumns = reset($assoc['joinColumns']); + $isNullable = ! isset($joinColumns['nullable']) || $joinColumns['nullable']; + + // Add dependency. The dependency direction implies that "$targetEntity has to go before $entity", + // so we can work through the topo sort result from left to right (with all edges pointing right). + $sort->addEdge($targetEntity, $entity, $isNullable); } } - return $result; + return $sort->sort(); } /** @return list */ diff --git a/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php b/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php index 6c211aa603c..9cefa18ab7c 100644 --- a/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php @@ -74,9 +74,11 @@ public function testFind(): void public function testEagerLoadsAssociation(): void { - $this->createFixture(); + $customerId = $this->createFixture(); + + $query = $this->_em->createQuery('select c, m from Doctrine\Tests\Models\ECommerce\ECommerceCustomer c left join c.mentor m where c.id = :id'); + $query->setParameter('id', $customerId); - $query = $this->_em->createQuery('select c, m from Doctrine\Tests\Models\ECommerce\ECommerceCustomer c left join c.mentor m order by c.id asc'); $result = $query->getResult(); $customer = $result[0]; $this->assertLoadingOfAssociation($customer); diff --git a/tests/Doctrine/Tests/OrmFunctionalTestCase.php b/tests/Doctrine/Tests/OrmFunctionalTestCase.php index c2054179d6f..b024f09f553 100644 --- a/tests/Doctrine/Tests/OrmFunctionalTestCase.php +++ b/tests/Doctrine/Tests/OrmFunctionalTestCase.php @@ -421,6 +421,7 @@ protected function tearDown(): void $conn->executeStatement('DELETE FROM ecommerce_products_categories'); $conn->executeStatement('DELETE FROM ecommerce_products_related'); $conn->executeStatement('DELETE FROM ecommerce_carts'); + $conn->executeStatement('DELETE FROM ecommerce_customers WHERE mentor_id IS NOT NULL'); $conn->executeStatement('DELETE FROM ecommerce_customers'); $conn->executeStatement('DELETE FROM ecommerce_features'); $conn->executeStatement('DELETE FROM ecommerce_products'); From aa27b3a35f67b4a4da1b5908f5366fe036ecd15a Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 31 May 2023 07:25:41 +0000 Subject: [PATCH 08/72] Remove the now unused UnitOfWork::getCommitOrder() method --- lib/Doctrine/ORM/UnitOfWork.php | 76 --------------------------------- psalm-baseline.xml | 1 - 2 files changed, 77 deletions(-) diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index 83bf461dc39..d59506c1e69 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -57,7 +57,6 @@ use function array_key_exists; use function array_map; use function array_merge; -use function array_pop; use function array_sum; use function array_values; use function assert; @@ -1367,81 +1366,6 @@ private function computeDeleteExecutionOrder(): array return $sort->sort(); } - /** - * Gets the commit order. - * - * @return list - */ - private function getCommitOrder(): array - { - $calc = $this->getCommitOrderCalculator(); - - // See if there are any new classes in the changeset, that are not in the - // commit order graph yet (don't have a node). - // We have to inspect changeSet to be able to correctly build dependencies. - // It is not possible to use IdentityMap here because post inserted ids - // are not yet available. - $newNodes = []; - - foreach (array_merge($this->entityInsertions, $this->entityUpdates, $this->entityDeletions) as $entity) { - $class = $this->em->getClassMetadata(get_class($entity)); - - if ($calc->hasNode($class->name)) { - continue; - } - - $calc->addNode($class->name, $class); - - $newNodes[] = $class; - } - - // Calculate dependencies for new nodes - while ($class = array_pop($newNodes)) { - foreach ($class->associationMappings as $assoc) { - if (! ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE)) { - continue; - } - - $targetClass = $this->em->getClassMetadata($assoc['targetEntity']); - - if (! $calc->hasNode($targetClass->name)) { - $calc->addNode($targetClass->name, $targetClass); - - $newNodes[] = $targetClass; - } - - $joinColumns = reset($assoc['joinColumns']); - - $calc->addDependency($targetClass->name, $class->name, (int) empty($joinColumns['nullable'])); - - // If the target class has mapped subclasses, these share the same dependency. - if (! $targetClass->subClasses) { - continue; - } - - foreach ($targetClass->subClasses as $subClassName) { - $targetSubClass = $this->em->getClassMetadata($subClassName); - - if (! $calc->hasNode($subClassName)) { - $calc->addNode($targetSubClass->name, $targetSubClass); - - $newNodes[] = $targetSubClass; - } - - $calc->addDependency($targetSubClass->name, $class->name, 1); - } - } - } - - // Remove duplicate class entries - $result = []; - foreach ($calc->sort() as $classMetadata) { - $result[$classMetadata->name] = $classMetadata; - } - - return array_values($result); - } - /** * Schedules an entity for insertion into the database. * If the entity already has an identifier, it will be added to the identity map. diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 584de36bc9f..7fd2e359877 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -3031,7 +3031,6 @@ setValue - From 04e08640fbea690ef3d8882bfcbba5bc776936d1 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 31 May 2023 07:59:00 +0000 Subject: [PATCH 09/72] Add a test case to show #10348 has been fixed by #10566 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is part of the series of issues fixed by #10547. In particular, the changes from #10566 were relevant. See #10348 for the bug description. Co-authored-by: Grégoire Paris --- .../ORM/Functional/Ticket/GH10348Test.php | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH10348Test.php diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10348Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10348Test.php new file mode 100644 index 00000000000..1e4f0c47c94 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10348Test.php @@ -0,0 +1,108 @@ +setUpEntitySchema([ + GH10348Person::class, + GH10348Company::class, + ]); + } + + public function testTheORMRemovesReferencedEmployeeBeforeReferencingEmployee(): void + { + $person1 = new GH10348Person(); + $person2 = new GH10348Person(); + $person2->mentor = $person1; + + $company = new GH10348Company(); + $company->addEmployee($person1)->addEmployee($person2); + + $this->_em->persist($company); + $this->_em->flush(); + + $company = $this->_em->find(GH10348Company::class, $company->id); + + $this->_em->remove($company); + $this->_em->flush(); + + self::assertEmpty($this->_em->createQuery('SELECT c FROM ' . GH10348Company::class . ' c')->getResult()); + self::assertEmpty($this->_em->createQuery('SELECT p FROM ' . GH10348Person::class . ' p')->getResult()); + } +} + +/** + * @ORM\Entity + */ +class GH10348Person +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue + * + * @var ?int + */ + public $id = null; + + /** + * @ORM\ManyToOne(targetEntity="GH10348Company", inversedBy="employees") + * + * @var ?GH10348Company + */ + public $employer = null; + + /** + * @ORM\ManyToOne(targetEntity="GH10348Person", cascade={"remove"}) + * + * @var ?GH10348Person + */ + public $mentor = null; +} + +/** + * @ORM\Entity + */ +class GH10348Company +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue + * + * @var ?int + */ + public $id = null; + + /** + * @ORM\OneToMany(targetEntity="GH10348Person", mappedBy="emplo", cascade={"persist", "remove"}) + * + * @var Collection + */ + private $employees; + + public function __construct() + { + $this->employees = new ArrayCollection(); + } + + public function addEmployee(GH10348Person $person): self + { + $person->employer = $this; + $this->employees->add($person); + + return $this; + } +} From 62172855440442384d1ea27363dbefc67f24a854 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Fri, 2 Jun 2023 17:52:51 +0000 Subject: [PATCH 10/72] Add test to show #7006 has been fixed --- .../ORM/Functional/Ticket/GH7006Test.php | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH7006Test.php diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7006Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7006Test.php new file mode 100644 index 00000000000..fe43b0d684e --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7006Test.php @@ -0,0 +1,135 @@ +createSchemaForModels(GH7006Book::class, GH7006PCT::class, GH7006PCTFee::class); + } + + public function testIssue(): void + { + $book = new GH7006Book(); + $book->exchangeCode = 'first'; + $this->_em->persist($book); + + $book->exchangeCode = 'second'; // change sth. + + $paymentCardTransaction = new GH7006PCT(); + $paymentCardTransaction->book = $book; + $paymentCardTransactionFee = new GH7006PCTFee($paymentCardTransaction); + + $this->_em->persist($paymentCardTransaction); + + $this->_em->flush(); + + self::assertIsInt($book->id); + self::assertIsInt($paymentCardTransaction->id); + self::assertIsInt($paymentCardTransactionFee->id); + } +} + +/** + * @ORM\Entity + */ +class GH7006Book +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + * + * @var int + */ + public $id; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + * + * @var string + */ + public $exchangeCode; + + /** + * @ORM\OneToOne(targetEntity="GH7006PCT", cascade={"persist", "remove"}) + * @ORM\JoinColumn(name="paymentCardTransactionId", referencedColumnName="id") + * + * @var GH7006PCT + */ + public $paymentCardTransaction; +} + +/** + * @ORM\Entity + */ +class GH7006PCT +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + * + * @var int + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity="GH7006Book") + * @ORM\JoinColumn(name="bookingId", referencedColumnName="id", nullable=false) + * + * @var GH7006Book + */ + public $book; + + /** + * @ORM\OneToMany(targetEntity="GH7006PCTFee", mappedBy="pct", cascade={"persist", "remove"}) + * @ORM\OrderBy({"id" = "ASC"}) + * + * @var GH7006PCTFee[] + */ + public $fees; + + public function __construct() + { + $this->fees = new ArrayCollection(); + } +} + +/** + * @ORM\Entity + */ +class GH7006PCTFee +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + * + * @var int + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity="GH7006PCT", inversedBy="fees") + * @ORM\JoinColumn(name="paymentCardTransactionId", referencedColumnName="id", nullable=false) + * + * @var GH7006PCT + */ + public $pct; + + public function __construct(GH7006PCT $pct) + { + $this->pct = $pct; + $pct->fees->add($this); + } +} From a72a0c3597568f828db22da70b884840f462789e Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Fri, 2 Jun 2023 22:00:20 +0200 Subject: [PATCH 11/72] Update tests/Doctrine/Tests/ORM/Functional/Ticket/GH7006Test.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Grégoire Paris --- tests/Doctrine/Tests/ORM/Functional/Ticket/GH7006Test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7006Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7006Test.php index fe43b0d684e..e4be3ed83b8 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7006Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7006Test.php @@ -95,7 +95,7 @@ class GH7006PCT * @ORM\OneToMany(targetEntity="GH7006PCTFee", mappedBy="pct", cascade={"persist", "remove"}) * @ORM\OrderBy({"id" = "ASC"}) * - * @var GH7006PCTFee[] + * @var Collection */ public $fees; From aad875eea174a5cb7b13c14c78ea5b3eca83d464 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Fri, 2 Jun 2023 17:44:45 +0000 Subject: [PATCH 12/72] Add tests to show #6499 has been fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @frikkle was the first to propose these tests in #6533. @rvanlaak followed up in #8703, making some adjustments. Co-authored-by: Gabe van der Weijde Co-authored-by: Richard van Laak Co-authored-by: Grégoire Paris --- .../GH6499OneToManyRelationshipTest.php | 155 ++++++++++++++++++ .../Ticket/GH6499OneToOneRelationshipTest.php | 78 +++++++++ .../ORM/Functional/Ticket/GH6499Test.php | 103 ++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499OneToManyRelationshipTest.php create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499OneToOneRelationshipTest.php create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499Test.php diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499OneToManyRelationshipTest.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499OneToManyRelationshipTest.php new file mode 100644 index 00000000000..f638e79f013 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499OneToManyRelationshipTest.php @@ -0,0 +1,155 @@ +createSchemaForModels(Application::class, Person::class, ApplicationPerson::class); + } + + /** + * Test for the bug described in issue #6499. + */ + public function testIssue(): void + { + $person = new Person(); + $this->_em->persist($person); + + $application = new Application(); + $this->_em->persist($application); + + $applicationPerson = new ApplicationPerson($person, $application); + + $this->_em->persist($applicationPerson); + $this->_em->flush(); + $this->_em->clear(); + + $personFromDatabase = $this->_em->find(Person::class, $person->id); + $applicationFromDatabase = $this->_em->find(Application::class, $application->id); + + self::assertEquals($personFromDatabase->id, $person->id, 'Issue #6499 will result in an integrity constraint violation before reaching this point.'); + self::assertFalse($personFromDatabase->getApplicationPeople()->isEmpty()); + + self::assertEquals($applicationFromDatabase->id, $application->id, 'Issue #6499 will result in an integrity constraint violation before reaching this point.'); + self::assertFalse($applicationFromDatabase->getApplicationPeople()->isEmpty()); + } +} + +/** + * @ORM\Entity + * @ORM\Table("GH6499OTM_application") + */ +class Application +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + * + * @var int + */ + public $id; + + /** + * @ORM\OneToMany(targetEntity=ApplicationPerson::class, mappedBy="application", orphanRemoval=true, cascade={"persist"}) + * @ORM\JoinColumn(nullable=false) + * + * @var Collection + */ + private $applicationPeople; + + public function __construct() + { + $this->applicationPeople = new ArrayCollection(); + } + + public function getApplicationPeople(): Collection + { + return $this->applicationPeople; + } +} +/** + * @ORM\Entity() + * @ORM\Table("GH6499OTM_person") + */ +class Person +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + * + * @var int + */ + public $id; + + /** + * @ORM\OneToMany(targetEntity=ApplicationPerson::class, mappedBy="person", orphanRemoval=true, cascade={"persist"}) + * @ORM\JoinColumn(nullable=false) + * + * @var Collection + */ + private $applicationPeople; + + public function __construct() + { + $this->applicationPeople = new ArrayCollection(); + } + + public function getApplicationPeople(): Collection + { + return $this->applicationPeople; + } +} + +/** + * @ORM\Entity() + * @ORM\Table("GH6499OTM_application_person") + */ +class ApplicationPerson +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + * + * @var int + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity=Application::class, inversedBy="applicationPeople", cascade={"persist"}) + * @ORM\JoinColumn(nullable=false) + * + * @var Application + */ + public $application; + + /** + * @ORM\ManyToOne(targetEntity=Person::class, inversedBy="applicationPeople", cascade={"persist"}) + * @ORM\JoinColumn(nullable=false) + * + * @var Person + */ + public $person; + + public function __construct(Person $person, Application $application) + { + $this->person = $person; + $this->application = $application; + } +} diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499OneToOneRelationshipTest.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499OneToOneRelationshipTest.php new file mode 100644 index 00000000000..2922c674733 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499OneToOneRelationshipTest.php @@ -0,0 +1,78 @@ +createSchemaForModels(GH6499OTOA::class, GH6499OTOB::class); + } + + /** + * Test for the bug described in issue #6499. + */ + public function testIssue(): void + { + $a = new GH6499OTOA(); + + $this->_em->persist($a); + $this->_em->flush(); + $this->_em->clear(); + + self::assertEquals( + $this->_em->find(GH6499OTOA::class, $a->id)->b->id, + $a->b->id, + 'Issue #6499 will result in an integrity constraint violation before reaching this point.' + ); + } +} + +/** @ORM\Entity */ +class GH6499OTOA +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue + * + * @var int + */ + public $id; + + /** + * @ORM\OneToOne(targetEntity="GH6499OTOB", cascade={"persist"}) + * @ORM\JoinColumn(nullable=false) + * + * @var GH6499OTOB + */ + public $b; + + public function __construct() + { + $this->b = new GH6499OTOB(); + } +} + +/** @ORM\Entity */ +class GH6499OTOB +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue + * + * @var int + */ + public $id; +} diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499Test.php new file mode 100644 index 00000000000..a6672801ca4 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6499Test.php @@ -0,0 +1,103 @@ +createSchemaForModels(GH6499A::class, GH6499B::class); + } + + public function testIssue(): void + { + $b = new GH6499B(); + $a = new GH6499A(); + + $this->_em->persist($a); + + $a->b = $b; + + $this->_em->persist($b); + + $this->_em->flush(); + + self::assertIsInt($a->id); + self::assertIsInt($b->id); + } + + public function testIssueReversed(): void + { + $b = new GH6499B(); + $a = new GH6499A(); + + $a->b = $b; + + $this->_em->persist($b); + $this->_em->persist($a); + + $this->_em->flush(); + + self::assertIsInt($a->id); + self::assertIsInt($b->id); + } +} + +/** + * @ORM\Entity + */ +class GH6499A +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue + * + * @var int + */ + public $id; + + /** + * @ORM\JoinColumn(nullable=false) + * @ORM\OneToOne(targetEntity=GH6499B::class) + * + * @var GH6499B + */ + public $b; +} + +/** + * @ORM\Entity + */ +class GH6499B +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue + * + * @var int + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity=GH6499A::class) + * + * @var GH6499A + */ + private $a; +} From dd0e02e912c45621b0928998c1d4257400681068 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Fri, 2 Jun 2023 21:28:11 +0000 Subject: [PATCH 13/72] Add test to show #7180 has been fixed Tests suggested in https://github.com/doctrine/orm/pull/7180#issuecomment-380841413 and https://github.com/doctrine/orm/pull/7180#issuecomment-381067448 by @arnaud-lb. Co-authored-by: Arnaud Le Blanc --- .../ORM/Functional/Ticket/GH7180Test.php | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH7180Test.php diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7180Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7180Test.php new file mode 100644 index 00000000000..07a9389d1b0 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7180Test.php @@ -0,0 +1,217 @@ +setUpEntitySchema([GH7180A::class, GH7180B::class, GH7180C::class, GH7180D::class, GH7180E::class, GH7180F::class, GH7180G::class]); + } + + public function testIssue(): void + { + $a = new GH7180A(); + $b = new GH7180B(); + $c = new GH7180C(); + + $a->b = $b; + $b->a = $a; + $c->a = $a; + + $this->_em->persist($a); + $this->_em->persist($b); + $this->_em->persist($c); + + $this->_em->flush(); + + self::assertIsInt($a->id); + self::assertIsInt($b->id); + self::assertIsInt($c->id); + } + + public function testIssue3NodeCycle(): void + { + $d = new GH7180D(); + $e = new GH7180E(); + $f = new GH7180F(); + $g = new GH7180G(); + + $d->e = $e; + $e->f = $f; + $f->d = $d; + $g->d = $d; + + $this->_em->persist($d); + $this->_em->persist($e); + $this->_em->persist($f); + $this->_em->persist($g); + + $this->_em->flush(); + + self::assertIsInt($d->id); + self::assertIsInt($e->id); + self::assertIsInt($f->id); + self::assertIsInt($g->id); + } +} + +/** + * @Entity + */ +class GH7180A +{ + /** + * @GeneratedValue() + * @Id @Column(type="integer") + * @var int + */ + public $id; + + /** + * @OneToOne(targetEntity=GH7180B::class, inversedBy="a") + * @JoinColumn(nullable=false) + * @var GH7180B + */ + public $b; +} + +/** + * @Entity + */ +class GH7180B +{ + /** + * @GeneratedValue() + * @Id + * @Column(type="integer") + * @var int + */ + public $id; + + /** + * @OneToOne(targetEntity=GH7180A::class, mappedBy="b") + * @JoinColumn(nullable=true) + * @var GH7180A + */ + public $a; +} + +/** + * @Entity + */ +class GH7180C +{ + /** + * @GeneratedValue() + * @Id @Column(type="integer") + * @var int + */ + public $id; + + /** + * @ManyToOne(targetEntity=GH7180A::class) + * @JoinColumn(nullable=false) + * @var GH7180A + */ + public $a; +} + +/** + * @Entity + */ +class GH7180D +{ + /** + * @GeneratedValue() + * @Id @Column(type="integer") + * @var int + */ + public $id; + + /** + * @OneToOne(targetEntity=GH7180E::class) + * @JoinColumn(nullable=false) + * @var GH7180E + */ + public $e; +} + +/** + * @Entity + */ +class GH7180E +{ + /** + * @GeneratedValue() + * @Id @Column(type="integer") + * @var int + */ + public $id; + + /** + * @OneToOne(targetEntity=GH7180F::class) + * @JoinColumn(nullable=false) + * @var GH7180F + */ + public $f; +} + +/** + * @Entity + */ +class GH7180F +{ + /** + * @GeneratedValue() + * @Id @Column(type="integer") + * @var int + */ + public $id; + + /** + * @ManyToOne(targetEntity=GH7180D::class) + * @JoinColumn(nullable=true) + * @var GH7180D + */ + public $d; +} + +/** + * @Entity + */ +class GH7180G +{ + /** + * @GeneratedValue() + * @Id @Column(type="integer") + * @var int + */ + public $id; + + /** + * @ManyToOne(targetEntity=GH7180D::class) + * @JoinColumn(nullable=false) + * @var GH7180D + */ + public $d; +} From bf2937e63a62aea7b3cfae59a1524572f6119c14 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Tue, 21 Feb 2023 22:26:57 +0000 Subject: [PATCH 14/72] Add test: Entity insertions must not happen table-wise Add tests for entity insertion and deletion that require writes to different tables in an interleaved fashion, and that have to re-visit a particular table. #### Background In #10531, I've given an example where it is necessary to compute the commit order on the entity (instead of table) level. Taking a closer look at the UoW to see how this could be achieved, I noticed that the current, table-level commit order manifests itself also in the API between the UoW and `EntityPersister`s. #### Current situation The UoW computes the commit order on the table level. All entity insertions for a particular table are passed through `EntityPersister::addInsert()` and finally written through `EntityPersister::executeInserts()`. #### Suggested change The test in this PR contains a carefully constructed set of four entities. Two of them are of the same class (are written to the same table), but require other entities to be processed first. In order to be able to insert this set of entities, the ORM must be able to perform inserts for a given table repeatedly, interleaved with writing other entities to their respective tables. --- .../ORM/Functional/Ticket/GH10532Test.php | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH10532Test.php diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10532Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10532Test.php new file mode 100644 index 00000000000..b3762b068e4 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10532Test.php @@ -0,0 +1,186 @@ +createSchemaForModels( + GH10532A::class, + GH10532B::class, + GH10532C::class, + GH10532X::class + ); + } + + public function tearDown(): void + { + $conn = static::$sharedConn; + $conn->executeStatement('DELETE FROM gh10532_c'); + $conn->executeStatement('DELETE FROM gh10532_b'); + $conn->executeStatement('DELETE FROM gh10532_a'); + $conn->executeStatement('DELETE FROM gh10532_x'); + } + + public function testInserts(): void + { + // Dependencies are $a1 -> $b -> $a2 -> $c + + $a1 = new GH10532A(); + $b = new GH10532B(); + $a2 = new GH10532A(); + $c = new GH10532C(); + + $a1->x = $b; + $b->a = $a2; + $a2->x = $c; + + /* + * The following would force a working commit order, but that's not what + * we want (the ORM shall sort this out internally). + * + * $this->_em->persist($c); + * $this->_em->persist($a2); + * $this->_em->flush(); + * $this->_em->persist($b); + * $this->_em->persist($a1); + * $this->_em->flush(); + */ + + $this->_em->persist($a1); + $this->_em->persist($a2); + $this->_em->persist($b); + $this->_em->persist($c); + $this->_em->flush(); + + self::assertNotNull($a1->id); + self::assertNotNull($b->id); + self::assertNotNull($a2->id); + self::assertNotNull($c->id); + } + + public function testDeletes(): void + { + // Dependencies are $a1 -> $b -> $a2 -> $c + + $this->expectNotToPerformAssertions(); + $con = $this->_em->getConnection(); + + // The "c" entity + $con->insert('gh10532_x', ['id' => 1, 'discr' => 'C']); + $con->insert('gh10532_c', ['id' => 1]); + $c = $this->_em->find(GH10532C::class, 1); + + // The "a2" entity + $con->insert('gh10532_a', ['id' => 2, 'gh10532x_id' => 1]); + $a2 = $this->_em->find(GH10532A::class, 2); + + // The "b" entity + $con->insert('gh10532_x', ['id' => 3, 'discr' => 'B']); + $con->insert('gh10532_b', ['id' => 3, 'gh10532a_id' => 2]); + $b = $this->_em->find(GH10532B::class, 3); + + // The "a1" entity + $con->insert('gh10532_a', ['id' => 4, 'gh10532x_id' => 3]); + $a1 = $this->_em->find(GH10532A::class, 4); + + /* + * The following would make the deletions happen in an order + * where the not-nullable foreign key constraints would not be + * violated. But, we want the ORM to be able to sort this out + * internally. + * + * $this->_em->remove($a1); + * $this->_em->flush(); + * $this->_em->remove($b); + * $this->_em->flush(); + * $this->_em->remove($a2); + * $this->_em->remove($c); + * $this->_em->flush(); + */ + + $this->_em->remove($a1); + $this->_em->remove($a2); + $this->_em->remove($b); + $this->_em->remove($c); + + $this->_em->flush(); + } +} + +/** + * @ORM\Entity + * @ORM\Table(name="gh10532_x") + * @ORM\DiscriminatorColumn(name="discr", type="string") + * @ORM\DiscriminatorMap({ "B": "GH10532B", "C": "GH10532C" }) + * @ORM\InheritanceType("JOINED") + * + * We are using JTI here, since STI would relax the not-nullable constraint for the "parent" + * column. Causes another error, but not the constraint violation I'd like to point out. + */ +abstract class GH10532X +{ + /** + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + * @ORM\Column(type="integer") + * + * @var int + */ + public $id; +} + +/** + * @ORM\Entity + * @ORM\Table(name="gh10532_b") + */ +class GH10532B extends GH10532X +{ + /** + * @ORM\ManyToOne(targetEntity="GH10532A") + * @ORM\JoinColumn(nullable=false, name="gh10532a_id") + * + * @var GH10532A + */ + public $a; +} + +/** + * @ORM\Entity + * @ORM\Table(name="gh10532_c") + */ +class GH10532C extends GH10532X +{ +} + +/** + * @ORM\Entity + * @ORM\Table(name="gh10532_a") + */ +class GH10532A +{ + /** + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + * @ORM\Column(type="integer") + * + * @var int + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity="GH10532X") + * @ORM\JoinColumn(nullable=false, name="gh10532x_id") + * + * @var GH10532X + */ + public $x; +} From 79f53d5dae7f1f432aa9273269eaf3c36883e1e9 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Sun, 4 Jun 2023 19:46:16 +0000 Subject: [PATCH 15/72] Add test to show #9192 has been fixed This test implements the situation described in #9192. The commit order computation will be fixed by #10547. --- .../ORM/Functional/Ticket/GH9192Test.php | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH9192Test.php diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9192Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9192Test.php new file mode 100644 index 00000000000..216c4f2dff9 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9192Test.php @@ -0,0 +1,136 @@ +createSchemaForModels(GH9192A::class, GH9192B::class, GH9192C::class); + } + + public function testIssue(): void + { + $a = new GH9192A(); + + $b = new GH9192B(); + $b->a = $a; + $a->bs->add($b); + + $c = new GH9192C(); + $c->b = $b; + $b->cs->add($c); + + $a->c = $c; + + $this->_em->persist($a); + $this->_em->persist($b); + $this->_em->persist($c); + $this->_em->flush(); + + $this->expectNotToPerformAssertions(); + + $this->_em->remove($a); + $this->_em->flush(); + } +} + +/** + * @ORM\Entity + */ +class GH9192A +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + * + * @var int + */ + public $id; + + /** + * @ORM\OneToMany(targetEntity="GH9192B", mappedBy="a", cascade={"remove"}) + * + * @var Collection + */ + public $bs; + + /** + * @ORM\OneToOne(targetEntity="GH9192C") + * @ORM\JoinColumn(nullable=true, onDelete="SET NULL") + * + * @var GH9192C + */ + public $c; + + public function __construct() + { + $this->bs = new ArrayCollection(); + } +} + +/** + * @ORM\Entity + */ +class GH9192B +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + * + * @var int + */ + public $id; + + /** + * @ORM\OneToMany(targetEntity="GH9192C", mappedBy="b", cascade={"remove"}) + * + * @var Collection + */ + public $cs; + + /** + * @ORM\ManyToOne(targetEntity="GH9192A", inversedBy="bs") + * + * @var GH9192A + */ + public $a; + + public function __construct() + { + $this->cs = new ArrayCollection(); + } +} + +/** + * @ORM\Entity + */ +class GH9192C +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + * + * @var int + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity="GH9192B", inversedBy="cs") + * + * @var GH9192B + */ + public $b; +} From 338deacb5825b4fca43f601f98a7234b9d20ea81 Mon Sep 17 00:00:00 2001 From: Minh Vuong Date: Thu, 22 Jun 2023 17:02:24 +0700 Subject: [PATCH 16/72] fix: attach entity listener when reset metadata factory --- .../ORM/Tools/AttachEntityListenersListener.php | 2 -- .../ORM/Tools/AttachEntityListenersListenerTest.php | 11 +++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/Doctrine/ORM/Tools/AttachEntityListenersListener.php b/lib/Doctrine/ORM/Tools/AttachEntityListenersListener.php index 8b3e7977ad4..1193db40f38 100644 --- a/lib/Doctrine/ORM/Tools/AttachEntityListenersListener.php +++ b/lib/Doctrine/ORM/Tools/AttachEntityListenersListener.php @@ -56,7 +56,5 @@ public function loadClassMetadata(LoadClassMetadataEventArgs $event) $metadata->addEntityListener($listener['event'], $listener['class'], $listener['method']); } } - - unset($this->entityListeners[$metadata->name]); } } diff --git a/tests/Doctrine/Tests/ORM/Tools/AttachEntityListenersListenerTest.php b/tests/Doctrine/Tests/ORM/Tools/AttachEntityListenersListenerTest.php index 485794e331a..21c9df32f4a 100644 --- a/tests/Doctrine/Tests/ORM/Tools/AttachEntityListenersListenerTest.php +++ b/tests/Doctrine/Tests/ORM/Tools/AttachEntityListenersListenerTest.php @@ -56,6 +56,17 @@ public function testAttachEntityListeners(): void self::assertCount(1, $metadata->entityListeners['postLoad']); self::assertEquals('postLoadHandler', $metadata->entityListeners['postLoad'][0]['method']); self::assertEquals(AttachEntityListenersListenerTestListener::class, $metadata->entityListeners['postLoad'][0]['class']); + + // Can reattach entity listeners even class metadata factory recreated. + $factory2 = new ClassMetadataFactory(); + $factory2->setEntityManager($this->em); + + $metadata2 = $factory2->getMetadataFor(AttachEntityListenersListenerTestFooEntity::class); + + self::assertArrayHasKey('postLoad', $metadata2->entityListeners); + self::assertEquals(AttachEntityListenersListenerTestListener::class, $metadata2->entityListeners['postLoad'][0]['class']); + self::assertCount(1, $metadata2->entityListeners['postLoad']); + self::assertEquals('postLoadHandler', $metadata2->entityListeners['postLoad'][0]['method']); } public function testAttachToExistingEntityListeners(): void From 8bc74c624ac13ce3430b8704be249d13952e68fb Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Fri, 2 Jun 2023 09:59:44 +0000 Subject: [PATCH 17/72] Make EntityPersisters tell the UoW about post insert IDs early This refactoring does two things: * We can avoid collecting the post insert IDs in a cumbersome array structure that will be returned by the EntityPersisters and processed by the UoW right after. Instead, use a more expressive API: Make the EntityPersisters tell the UoW about the IDs immediately. * IDs will be available in inserted entities a tad sooner. That may help to resolve #10735, where we can use the IDs to skip extra updates. --- UPGRADE.md | 6 ++ .../Entity/BasicEntityPersister.php | 16 ++--- .../ORM/Persisters/Entity/EntityPersister.php | 10 ++- .../Entity/JoinedSubclassPersister.php | 16 ++--- lib/Doctrine/ORM/UnitOfWork.php | 62 ++++++++++++------- .../JoinedSubclassPersisterTest.php | 38 ------------ 6 files changed, 63 insertions(+), 85 deletions(-) delete mode 100644 tests/Doctrine/Tests/ORM/Persisters/JoinedSubclassPersisterTest.php diff --git a/UPGRADE.md b/UPGRADE.md index a583a83fffc..bf2d9b385db 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,11 @@ # Upgrade to 2.16 +## Deprecated returning post insert IDs from `EntityPersister::executeInserts()` + +Persisters implementing `\Doctrine\ORM\Persisters\Entity\EntityPersister` should no longer +return an array of post insert IDs from their `::executeInserts()` method. Make the +persister call `Doctrine\ORM\UnitOfWork::assignPostInsertId()` instead. + ## Changing the way how reflection-based mapping drivers report fields, deprecated the "old" mode In ORM 3.0, a change will be made regarding how the `AttributeDriver` reports field mappings. diff --git a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php index 623c2cb61c1..1de3d203d17 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php @@ -256,10 +256,10 @@ public function getInserts() public function executeInserts() { if (! $this->queuedInserts) { - return []; + return; } - $postInsertIds = []; + $uow = $this->em->getUnitOfWork(); $idGenerator = $this->class->idGenerator; $isPostInsertId = $idGenerator->isPostInsertGenerator(); @@ -280,12 +280,10 @@ public function executeInserts() $stmt->executeStatement(); if ($isPostInsertId) { - $generatedId = $idGenerator->generateId($this->em, $entity); - $id = [$this->class->identifier[0] => $generatedId]; - $postInsertIds[] = [ - 'generatedId' => $generatedId, - 'entity' => $entity, - ]; + $generatedId = $idGenerator->generateId($this->em, $entity); + $id = [$this->class->identifier[0] => $generatedId]; + + $uow->assignPostInsertId($entity, $generatedId); } else { $id = $this->class->getIdentifierValues($entity); } @@ -296,8 +294,6 @@ public function executeInserts() } $this->queuedInserts = []; - - return $postInsertIds; } /** diff --git a/lib/Doctrine/ORM/Persisters/Entity/EntityPersister.php b/lib/Doctrine/ORM/Persisters/Entity/EntityPersister.php index 7f9d54450bf..a69fbbdae12 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/EntityPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/EntityPersister.php @@ -109,17 +109,15 @@ public function getSelectConditionStatementSQL($field, $value, $assoc = null, $c public function addInsert($entity); /** - * Executes all queued entity insertions and returns any generated post-insert - * identifiers that were created as a result of the insertions. + * Executes all queued entity insertions. * * If no inserts are queued, invoking this method is a NOOP. * - * @psalm-return list An array of any generated post-insert IDs. This will be - * an empty array if the entity class does not use the - * IDENTITY generation strategy. + * }> Returning an array of generated post-insert IDs is deprecated, implementations + * should call UnitOfWork::assignPostInsertId() and return void. */ public function executeInserts(); diff --git a/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php b/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php index 4a84ce5786f..c872f0a0f69 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php @@ -109,10 +109,10 @@ public function getOwningTable($fieldName) public function executeInserts() { if (! $this->queuedInserts) { - return []; + return; } - $postInsertIds = []; + $uow = $this->em->getUnitOfWork(); $idGenerator = $this->class->idGenerator; $isPostInsertId = $idGenerator->isPostInsertGenerator(); $rootClass = $this->class->name !== $this->class->rootEntityName @@ -157,12 +157,10 @@ public function executeInserts() $rootTableStmt->executeStatement(); if ($isPostInsertId) { - $generatedId = $idGenerator->generateId($this->em, $entity); - $id = [$this->class->identifier[0] => $generatedId]; - $postInsertIds[] = [ - 'generatedId' => $generatedId, - 'entity' => $entity, - ]; + $generatedId = $idGenerator->generateId($this->em, $entity); + $id = [$this->class->identifier[0] => $generatedId]; + + $uow->assignPostInsertId($entity, $generatedId); } else { $id = $this->em->getUnitOfWork()->getEntityIdentifier($entity); } @@ -194,8 +192,6 @@ public function executeInserts() } $this->queuedInserts = []; - - return $postInsertIds; } /** diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index bc8af25fbff..f192bd4b680 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -1143,30 +1143,24 @@ private function executeInserts(ClassMetadata $class): void $postInsertIds = $persister->executeInserts(); - if ($postInsertIds) { + if (is_array($postInsertIds)) { + Deprecation::trigger( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/10743/', + 'Returning post insert IDs from \Doctrine\ORM\Persisters\Entity\EntityPersister::executeInserts() is deprecated and will not be supported in Doctrine ORM 3.0. Make the persister call Doctrine\ORM\UnitOfWork::assignPostInsertId() instead.' + ); + // Persister returned post-insert IDs foreach ($postInsertIds as $postInsertId) { - $idField = $class->getSingleIdentifierFieldName(); - $idValue = $this->convertSingleFieldIdentifierToPHPValue($class, $postInsertId['generatedId']); - - $entity = $postInsertId['entity']; - $oid = spl_object_id($entity); - - $class->reflFields[$idField]->setValue($entity, $idValue); - - $this->entityIdentifiers[$oid] = [$idField => $idValue]; - $this->entityStates[$oid] = self::STATE_MANAGED; - $this->originalEntityData[$oid][$idField] = $idValue; - - $this->addToIdentityMap($entity); + $this->assignPostInsertId($postInsertId['entity'], $postInsertId['generatedId']); } - } else { - foreach ($insertionsForClass as $oid => $entity) { - if (! isset($this->entityIdentifiers[$oid])) { - //entity was not added to identity map because some identifiers are foreign keys to new entities. - //add it now - $this->addToEntityIdentifiersAndEntityMap($class, $oid, $entity); - } + } + + foreach ($insertionsForClass as $oid => $entity) { + if (! isset($this->entityIdentifiers[$oid])) { + //entity was not added to identity map because some identifiers are foreign keys to new entities. + //add it now + $this->addToEntityIdentifiersAndEntityMap($class, $oid, $entity); } } @@ -3790,4 +3784,30 @@ private function normalizeIdentifier(ClassMetadata $targetClass, array $flatIden return $normalizedAssociatedId; } + + /** + * Assign a post-insert generated ID to an entity + * + * This is used by EntityPersisters after they inserted entities into the database. + * It will place the assigned ID values in the entity's fields and start tracking + * the entity in the identity map. + * + * @param object $entity + * @param mixed $generatedId + */ + final public function assignPostInsertId($entity, $generatedId): void + { + $class = $this->em->getClassMetadata(get_class($entity)); + $idField = $class->getSingleIdentifierFieldName(); + $idValue = $this->convertSingleFieldIdentifierToPHPValue($class, $generatedId); + $oid = spl_object_id($entity); + + $class->reflFields[$idField]->setValue($entity, $idValue); + + $this->entityIdentifiers[$oid] = [$idField => $idValue]; + $this->entityStates[$oid] = self::STATE_MANAGED; + $this->originalEntityData[$oid][$idField] = $idValue; + + $this->addToIdentityMap($entity); + } } diff --git a/tests/Doctrine/Tests/ORM/Persisters/JoinedSubclassPersisterTest.php b/tests/Doctrine/Tests/ORM/Persisters/JoinedSubclassPersisterTest.php deleted file mode 100644 index 896a35a9a62..00000000000 --- a/tests/Doctrine/Tests/ORM/Persisters/JoinedSubclassPersisterTest.php +++ /dev/null @@ -1,38 +0,0 @@ -em = $this->getTestEntityManager(); - $this->persister = new JoinedSubclassPersister($this->em, $this->em->getClassMetadata(RootClass::class)); - } - - /** @group DDC-3470 */ - public function testExecuteInsertsWillReturnEmptySetWithNoQueuedInserts(): void - { - self::assertSame([], $this->persister->executeInserts()); - } -} From aa3ff458c761cae40a8d49c5ac4108a2bd5f2205 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Tue, 21 Feb 2023 11:14:46 +0000 Subject: [PATCH 18/72] Add test: Commit order calculation must happen on the entity level Add tests for entity insertion and deletion that require the commit order calculation to happen on the entity level. This demonstrates the necessity for the changes in #10547. This PR contains two tests with carefully constructed entity relationships, where we have a non-nullable `parent` foreign key relationships between entities stored in the same table. Class diagram: ```mermaid classDiagram direction LR class A class B A --> B : parent B --|> A ``` Object graph: ```mermaid graph LR; b1 --> b2; b2 --> a; b3 --> b2; ``` #### Situation before #10547 The commit order is computed by looking at the associations at the _table_ (= _class_) level. Once the ordering of _tables_ has been found, entities being mapped to the same table will be processed in the order they were given to `persist()` or `remove()`. That means only a particular ordering of `persist()` or `remove()` calls (see comment in the test) works: For inserts, the order must be `$a, $b2, $b1, $b3` (or `... $b3, $b1`), for deletions `$b1, $b3, $b2, $a`. #### Situation with entity-level commit order computation (as in #10547) The ORM computes the commit order by considering associations at the _entity_ level. It will be able to find a working order by itself. --- .../ORM/Functional/Ticket/GH10531Test.php | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH10531Test.php diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10531Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10531Test.php new file mode 100644 index 00000000000..2b0f4fa5c19 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10531Test.php @@ -0,0 +1,151 @@ +createSchemaForModels( + GH10531A::class, + GH10531B::class + ); + } + + public function tearDown(): void + { + $conn = static::$sharedConn; + $conn->executeStatement('DELETE FROM gh10531_b'); + $conn->executeStatement('DELETE FROM gh10531_a'); + } + + public function testInserts(): void + { + $a = new GH10531A(); + $b1 = new GH10531B(); + $b2 = new GH10531B(); + $b3 = new GH10531B(); + + $b1->parent = $b2; + $b3->parent = $b2; + $b2->parent = $a; + + /* + * The following would force a working commit order, but that's not what + * we want (the ORM shall sort this out internally). + * + * $this->_em->persist($a); + * $this->_em->persist($b2); + * $this->_em->flush(); + * $this->_em->persist($b1); + * $this->_em->persist($b3); + * $this->_em->flush(); + */ + + // Pass $b2 to persist() between $b1 and $b3, so that any potential reliance upon the + // order of persist() calls is spotted: No matter if it is in the order that persist() + // was called or the other way round, in both cases there is an entity that will come + // "before" $b2 but depend on its primary key, so the ORM must re-order the inserts. + + $this->_em->persist($a); + $this->_em->persist($b1); + $this->_em->persist($b2); + $this->_em->persist($b3); + $this->_em->flush(); + + self::assertNotNull($a->id); + self::assertNotNull($b1->id); + self::assertNotNull($b2->id); + self::assertNotNull($b3->id); + } + + public function testDeletes(): void + { + $this->expectNotToPerformAssertions(); + $con = $this->_em->getConnection(); + + // The "a" entity + $con->insert('gh10531_a', ['id' => 1, 'discr' => 'A']); + $a = $this->_em->find(GH10531A::class, 1); + + // The "b2" entity + $con->insert('gh10531_a', ['id' => 2, 'discr' => 'B']); + $con->insert('gh10531_b', ['id' => 2, 'parent_id' => 1]); + $b2 = $this->_em->find(GH10531B::class, 2); + + // The "b1" entity + $con->insert('gh10531_a', ['id' => 3, 'discr' => 'B']); + $con->insert('gh10531_b', ['id' => 3, 'parent_id' => 2]); + $b1 = $this->_em->find(GH10531B::class, 3); + + // The "b3" entity + $con->insert('gh10531_a', ['id' => 4, 'discr' => 'B']); + $con->insert('gh10531_b', ['id' => 4, 'parent_id' => 2]); + $b3 = $this->_em->find(GH10531B::class, 4); + + /* + * The following would make the deletions happen in an order + * where the not-nullable foreign key constraints would not be + * violated. But, we want the ORM to be able to sort this out + * internally. + * + * $this->_em->remove($b1); + * $this->_em->remove($b3); + * $this->_em->remove($b2); + */ + + // As before, put $b2 in between $b1 and $b3 so that the order of the + // remove() calls alone (in either direction) does not solve the problem. + // The ORM will have to sort $b2 to be deleted last, after $b1 and $b3. + $this->_em->remove($b1); + $this->_em->remove($b2); + $this->_em->remove($b3); + + $this->_em->flush(); + } +} + +/** + * @ORM\Entity + * @ORM\Table(name="gh10531_a") + * @ORM\DiscriminatorColumn(name="discr", type="string") + * @ORM\DiscriminatorMap({ "A": "GH10531A", "B": "GH10531B" }) + * @ORM\InheritanceType("JOINED") + * + * We are using JTI here, since STI would relax the not-nullable constraint for the "parent" + * column (it has to be NULL when the row contains a GH10531A instance). Causes another error, + * but not the constraint violation I'd like to point out. + */ +class GH10531A +{ + /** + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + * @ORM\Column(type="integer") + * + * @var int + */ + public $id; +} + +/** + * @ORM\Entity + * @ORM\Table(name="gh10531_b") + */ +class GH10531B extends GH10531A +{ + /** + * @ORM\ManyToOne(targetEntity="GH10531A") + * @ORM\JoinColumn(nullable=false, name="parent_id") + * + * @var GH10531A + */ + public $parent; +} From d738ecfcfef047e429a2d2b7fac943c695e81d5f Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Thu, 22 Jun 2023 16:52:36 +0200 Subject: [PATCH 19/72] Avoid creating unmanaged proxy instances for referred-to entities during merge() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR tries to improve the situation/problem explained in #3037: Under certain conditions – there may be multiple and not all are known/well-understood – we may get inconsistencies between the `\Doctrine\ORM\UnitOfWork::$entityIdentifiers` and `\Doctrine\ORM\UnitOfWork::$identityMap` arrays. Since the `::$identityMap` is a plain array holding object references, objects contained in it cannot be garbage-collected. `::$entityIdentifiers`, however, is indexed by `spl_object_id` values. When those objects are destructed and/or garbage-collected, the OID may be reused and reassigned to other objects later on. When the OID re-assignment happens to be for another entity, the UoW may assume incorrect entity states and, for example, miss INSERT or UPDATE operations. One cause for such inconsistencies is _replacing_ identity map entries with other object instances: This makes it possible that the old object becomes GC'd, while its OID is not cleaned up. Since that is not a use case we need to support (IMHO), #10785 is about adding a safeguard against it. In this test shown here, the `merge()` operation is currently too eager in creating a proxy object for another referred-to entity. This proxy represents an entity already present in the identity map at that time, potentially leading to this problem later on. --- lib/Doctrine/ORM/UnitOfWork.php | 20 +++-- .../ORM/Functional/Ticket/GH7407Test.php | 87 +++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH7407Test.php diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index ebb5f12a01b..cc1c5ab22bc 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -3669,14 +3669,18 @@ private function mergeEntityStateIntoManagedCopy($entity, $managedCopy): void $targetClass = $this->em->getClassMetadata($assoc2['targetEntity']); $relatedId = $targetClass->getIdentifierValues($other); - if ($targetClass->subClasses) { - $other = $this->em->find($targetClass->name, $relatedId); - } else { - $other = $this->em->getProxyFactory()->getProxy( - $assoc2['targetEntity'], - $relatedId - ); - $this->registerManaged($other, $relatedId, []); + $other = $this->tryGetById($relatedId, $targetClass->name); + + if (! $other) { + if ($targetClass->subClasses) { + $other = $this->em->find($targetClass->name, $relatedId); + } else { + $other = $this->em->getProxyFactory()->getProxy( + $assoc2['targetEntity'], + $relatedId + ); + $this->registerManaged($other, $relatedId, []); + } } } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7407Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7407Test.php new file mode 100644 index 00000000000..3e010e177cb --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7407Test.php @@ -0,0 +1,87 @@ +useModelSet('cms'); + + parent::setUp(); + } + + public function testMergingEntitiesDoesNotCreateUnmanagedProxyReferences(): void + { + // 1. Create an article with a user; persist, flush and clear the entity manager + $user = new CmsUser(); + $user->username = 'Test'; + $user->name = 'Test'; + $this->_em->persist($user); + + $article = new CmsArticle(); + $article->topic = 'Test'; + $article->text = 'Test'; + $article->setAuthor($user); + $this->_em->persist($article); + + $this->_em->flush(); + $this->_em->clear(); + + // 2. Merge the user object back in: + // We get a new (different) entity object that represents the user instance + // which is now (through this object instance) managed by the EM/UoW + $mergedUser = $this->_em->merge($user); + $mergedUserOid = spl_object_id($mergedUser); + + // 3. Merge the article object back in, + // the returned entity object is the article instance as it is managed by the EM/UoW + $mergedArticle = $this->_em->merge($article); + $mergedArticleOid = spl_object_id($mergedArticle); + + self::assertSame($mergedUser, $mergedArticle->user, 'The $mergedArticle\'s #user property should hold the $mergedUser we obtained previously, since that\'s the only legitimate object instance representing the user from the UoW\'s point of view.'); + + // Inspect internal UoW state + $uow = $this->_em->getUnitOfWork(); + $entityIdentifiers = $this->grabProperty('entityIdentifiers', $uow); + $identityMap = $this->grabProperty('identityMap', $uow); + $entityStates = $this->grabProperty('entityStates', $uow); + + self::assertCount(2, $entityIdentifiers, 'UoW#entityIdentifiers contains exactly two OID -> ID value mapping entries one for the article, one for the user object'); + self::assertArrayHasKey($mergedArticleOid, $entityIdentifiers); + self::assertArrayHasKey($mergedUserOid, $entityIdentifiers); + + self::assertSame([ + $mergedUserOid => UnitOfWork::STATE_MANAGED, + $mergedArticleOid => UnitOfWork::STATE_MANAGED, + ], $entityStates, 'UoW#entityStates contains two OID -> state entries, one for the article, one for the user object'); + + self::assertCount(2, $entityIdentifiers); + self::assertArrayHasKey($mergedArticleOid, $entityIdentifiers); + self::assertArrayHasKey($mergedUserOid, $entityIdentifiers); + + self::assertSame([ + CmsUser::class => [$user->id => $mergedUser], + CmsArticle::class => [$article->id => $mergedArticle], + ], $identityMap, 'The identity map contains exactly two objects, the article and the user.'); + } + + private function grabProperty(string $name, UnitOfWork $uow) + { + $reflection = new ReflectionClass($uow); + $property = $reflection->getProperty($name); + $property->setAccessible(true); + + return $property->getValue($uow); + } +} From 1989531d4ff67a19832a8219a8ef69c84bd52d9b Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Fri, 23 Jun 2023 22:47:34 +0200 Subject: [PATCH 20/72] Update tests/Doctrine/Tests/ORM/Functional/Ticket/GH7407Test.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Grégoire Paris --- tests/Doctrine/Tests/ORM/Functional/Ticket/GH7407Test.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7407Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7407Test.php index 3e010e177cb..c12d3da3279 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7407Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7407Test.php @@ -76,6 +76,7 @@ public function testMergingEntitiesDoesNotCreateUnmanagedProxyReferences(): void ], $identityMap, 'The identity map contains exactly two objects, the article and the user.'); } + /** @return mixed */ private function grabProperty(string $name, UnitOfWork $uow) { $reflection = new ReflectionClass($uow); From efb50b9bdd5b8ebe87a8318fb6c16ad9f1d8580c Mon Sep 17 00:00:00 2001 From: Daniel Jurkovic Date: Fri, 23 Jun 2023 11:25:28 +0200 Subject: [PATCH 21/72] distinct() updates QueryBuilder state correctly Previously calling distinct() when the QueryBuilder was in clean state would cause subsequent getDQL() calls to ignore the distinct queryPart Fixes #10784 --- lib/Doctrine/ORM/QueryBuilder.php | 7 ++++++- tests/Doctrine/Tests/ORM/QueryBuilderTest.php | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/Doctrine/ORM/QueryBuilder.php b/lib/Doctrine/ORM/QueryBuilder.php index 115fddda58c..30ca64c41c2 100644 --- a/lib/Doctrine/ORM/QueryBuilder.php +++ b/lib/Doctrine/ORM/QueryBuilder.php @@ -809,7 +809,12 @@ public function select($select = null) */ public function distinct($flag = true) { - $this->dqlParts['distinct'] = (bool) $flag; + $flag = (bool) $flag; + + if ($this->dqlParts['distinct'] !== $flag) { + $this->dqlParts['distinct'] = $flag; + $this->state = self::STATE_DIRTY; + } return $this; } diff --git a/tests/Doctrine/Tests/ORM/QueryBuilderTest.php b/tests/Doctrine/Tests/ORM/QueryBuilderTest.php index b6fe839bbb8..28774189a29 100644 --- a/tests/Doctrine/Tests/ORM/QueryBuilderTest.php +++ b/tests/Doctrine/Tests/ORM/QueryBuilderTest.php @@ -1087,6 +1087,18 @@ public function testAddDistinct(): void self::assertEquals('SELECT DISTINCT u FROM Doctrine\Tests\Models\CMS\CmsUser u', $qb->getDQL()); } + public function testDistinctUpdatesState(): void + { + $qb = $this->entityManager->createQueryBuilder() + ->select('u') + ->from(CmsUser::class, 'u'); + + $qb->getDQL(); + $qb->distinct(); + + self::assertEquals('SELECT DISTINCT u FROM Doctrine\Tests\Models\CMS\CmsUser u', $qb->getDQL()); + } + /** @group DDC-2192 */ public function testWhereAppend(): void { From da29eb675cef62655b2cc857c4209c39159d72ce Mon Sep 17 00:00:00 2001 From: Minh Vuong Date: Mon, 26 Jun 2023 09:29:00 +0700 Subject: [PATCH 22/72] test: assert `postLoad` has data first --- .../Tests/ORM/Tools/AttachEntityListenersListenerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Doctrine/Tests/ORM/Tools/AttachEntityListenersListenerTest.php b/tests/Doctrine/Tests/ORM/Tools/AttachEntityListenersListenerTest.php index 21c9df32f4a..31223ee9262 100644 --- a/tests/Doctrine/Tests/ORM/Tools/AttachEntityListenersListenerTest.php +++ b/tests/Doctrine/Tests/ORM/Tools/AttachEntityListenersListenerTest.php @@ -64,9 +64,9 @@ public function testAttachEntityListeners(): void $metadata2 = $factory2->getMetadataFor(AttachEntityListenersListenerTestFooEntity::class); self::assertArrayHasKey('postLoad', $metadata2->entityListeners); - self::assertEquals(AttachEntityListenersListenerTestListener::class, $metadata2->entityListeners['postLoad'][0]['class']); self::assertCount(1, $metadata2->entityListeners['postLoad']); self::assertEquals('postLoadHandler', $metadata2->entityListeners['postLoad'][0]['method']); + self::assertEquals(AttachEntityListenersListenerTestListener::class, $metadata2->entityListeners['postLoad'][0]['class']); } public function testAttachToExistingEntityListeners(): void From dc411954ad4f23ed08ec8951c79b4b5526b000f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Mon, 26 Jun 2023 19:10:14 +0200 Subject: [PATCH 23/72] Upgrade to Psalm 5.13 This is a nice release as far as the ORM is concerned: - a small baseline reduction; - lots of useless calls to sprintf spotted. --- composer.json | 2 +- .../ORM/Mapping/Driver/AttributeDriver.php | 5 ++--- lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php | 14 ++++++-------- lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php | 4 ++-- lib/Doctrine/ORM/ORMSetup.php | 5 ++--- psalm-baseline.xml | 10 +--------- 6 files changed, 14 insertions(+), 26 deletions(-) diff --git a/composer.json b/composer.json index 0955d56aef0..73b43b6dc60 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "symfony/cache": "^4.4 || ^5.4 || ^6.0", "symfony/var-exporter": "^4.4 || ^5.4 || ^6.2", "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0", - "vimeo/psalm": "4.30.0 || 5.12.0" + "vimeo/psalm": "4.30.0 || 5.13.0" }, "conflict": { "doctrine/annotations": "<1.13 || >= 3.0" diff --git a/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php b/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php index 4b5d2a20f8d..65945d13011 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php @@ -23,7 +23,6 @@ use function constant; use function defined; use function get_class; -use function sprintf; use const PHP_VERSION_ID; @@ -56,10 +55,10 @@ class AttributeDriver extends CompatibilityAnnotationDriver public function __construct(array $paths) { if (PHP_VERSION_ID < 80000) { - throw new LogicException(sprintf( + throw new LogicException( 'The attribute metadata driver cannot be enabled on PHP 7. Please upgrade to PHP 8 or choose a different' . ' metadata driver.' - )); + ); } $this->reader = new AttributeReader(); diff --git a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php index f755ad5f9a7..47a70d62c39 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php @@ -50,27 +50,25 @@ class XmlDriver extends FileDriver public function __construct($locator, $fileExtension = self::DEFAULT_FILE_EXTENSION, bool $isXsdValidationEnabled = false) { if (! extension_loaded('simplexml')) { - throw new LogicException(sprintf( + throw new LogicException( 'The XML metadata driver cannot be enabled because the SimpleXML PHP extension is missing.' . ' Please configure PHP with SimpleXML or choose a different metadata driver.' - )); + ); } if (! $isXsdValidationEnabled) { Deprecation::trigger( 'doctrine/orm', 'https://github.com/doctrine/orm/pull/6728', - sprintf( - 'Using XML mapping driver with XSD validation disabled is deprecated' - . ' and will not be supported in Doctrine ORM 3.0.' - ) + 'Using XML mapping driver with XSD validation disabled is deprecated' + . ' and will not be supported in Doctrine ORM 3.0.' ); } if ($isXsdValidationEnabled && ! extension_loaded('dom')) { - throw new LogicException(sprintf( + throw new LogicException( 'XSD validation cannot be enabled because the DOM extension is missing.' - )); + ); } $this->isXsdValidationEnabled = $isXsdValidationEnabled; diff --git a/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php b/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php index 4a1882c0e16..c6ff83e0f17 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php @@ -48,11 +48,11 @@ public function __construct($locator, $fileExtension = self::DEFAULT_FILE_EXTENS ); if (! class_exists(Yaml::class)) { - throw new LogicException(sprintf( + throw new LogicException( 'The YAML metadata driver cannot be enabled because the "symfony/yaml" library' . ' is not installed. Please run "composer require symfony/yaml" or choose a different' . ' metadata driver.' - )); + ); } parent::__construct($locator, $fileExtension); diff --git a/lib/Doctrine/ORM/ORMSetup.php b/lib/Doctrine/ORM/ORMSetup.php index 88a5dbddee7..5fda999d051 100644 --- a/lib/Doctrine/ORM/ORMSetup.php +++ b/lib/Doctrine/ORM/ORMSetup.php @@ -24,7 +24,6 @@ use function class_exists; use function extension_loaded; use function md5; -use function sprintf; use function sys_get_temp_dir; final class ORMSetup @@ -72,11 +71,11 @@ public static function createDefaultAnnotationDriver( __METHOD__ ); if (! class_exists(AnnotationReader::class)) { - throw new LogicException(sprintf( + throw new LogicException( 'The annotation metadata driver cannot be enabled because the "doctrine/annotations" library' . ' is not installed. Please run "composer require doctrine/annotations" or choose a different' . ' metadata driver.' - )); + ); } $reader = new AnnotationReader(); diff --git a/psalm-baseline.xml b/psalm-baseline.xml index f42d399e7c5..249630341bb 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + IterableResult @@ -213,11 +213,6 @@ CacheProvider - - - directory, self::LOCK_EXTENSION)]]> - - (int) $defaultLifetime @@ -2456,9 +2451,6 @@ - - - From 4aadba65cefd4efc89e5946c8b0304656c269a4a Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Tue, 27 Jun 2023 00:16:55 +0200 Subject: [PATCH 24/72] Explain `EntityManager::getReference()` peculiarities (#10800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Explain `EntityManager::getReference()` peculiarities As one takeaway from https://github.com/doctrine/orm/issues/3037#issuecomment-1605657003 and #843, we should look into better explaining the `EntityManager::getReference()` method, it’s semantics, caveats and potential responsibilities placed on the user. This PR tries to do that, so it fixes #10797. * Update docs/en/reference/advanced-configuration.rst Co-authored-by: Grégoire Paris --------- Co-authored-by: Grégoire Paris --- docs/en/reference/advanced-configuration.rst | 42 +++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/docs/en/reference/advanced-configuration.rst b/docs/en/reference/advanced-configuration.rst index 2973e2e9ebb..fdd42aeb6c5 100644 --- a/docs/en/reference/advanced-configuration.rst +++ b/docs/en/reference/advanced-configuration.rst @@ -311,10 +311,12 @@ Reference Proxies The method ``EntityManager#getReference($entityName, $identifier)`` lets you obtain a reference to an entity for which the identifier -is known, without loading that entity from the database. This is -useful, for example, as a performance enhancement, when you want to -establish an association to an entity for which you have the -identifier. You could simply do this: +is known, without necessarily loading that entity from the database. +This is useful, for example, as a performance enhancement, when you +want to establish an association to an entity for which you have the +identifier. + +Consider the following example: .. code-block:: php @@ -324,15 +326,33 @@ identifier. You could simply do this: $item = $em->getReference('MyProject\Model\Item', $itemId); $cart->addItem($item); -Here, we added an Item to a Cart without loading the Item from the -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 +Whether the object being returned from ``EntityManager#getReference()`` +is a proxy or a direct instance of the entity class may depend on different +factors, including whether the entity has already been loaded into memory +or entity inheritance being used. But your code does not need to care +and in fact it **should not care**. Proxy objects should be transparent to your code. +When using the ``EntityManager#getReference()`` method, you need to be aware +of a few peculiarities. + +At the best case, the ORM can avoid querying the database at all. But, that +also means that this method will not throw an exception when an invalid value +for the ``$identifier`` parameter is passed. ``$identifier`` values are +not checked and there is no guarantee that the requested entity instance even +exists – the method will still return a proxy object. + +Its only when the proxy has to be fully initialized or associations cannot +be written to the database that invalid ``$identifier`` values may lead to +exceptions. + +The ``EntityManager#getReference()`` is mostly useful when you only +need a reference to some entity to make an association, like in the example +above. In that case, it can save you from loading data from the database +that you don't need. But remember – as soon as you read any property values +besides those making up the ID, a database request will be made to initialize +all fields. + Association proxies ~~~~~~~~~~~~~~~~~~~ From 4da8d3be9677f91f2e91d6c8f7d9056b67265cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Sun, 25 Jun 2023 18:45:17 +0200 Subject: [PATCH 25/72] Resort on Query::HINT_FORCE_PARTIAL_LOAD less A lot of our tests mention it, but I do not think it is important to the test. Maybe it was a way to have more efficient tests? Most times, removing the hint does not affect the test outcome. --- ...ManyToManyBidirectionalAssociationTest.php | 6 +- .../ORM/Functional/Ticket/GH6362Test.php | 3 +- .../ORM/Hydration/ObjectHydratorTest.php | 97 +++++++++---------- .../ORM/Query/LanguageRecognitionTest.php | 1 - 4 files changed, 49 insertions(+), 58 deletions(-) diff --git a/tests/Doctrine/Tests/ORM/Functional/ManyToManyBidirectionalAssociationTest.php b/tests/Doctrine/Tests/ORM/Functional/ManyToManyBidirectionalAssociationTest.php index 52bb08e47e3..2b5dcb953c4 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ManyToManyBidirectionalAssociationTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ManyToManyBidirectionalAssociationTest.php @@ -109,8 +109,7 @@ private function createLoadingFixture(): void /** @psalm-return list */ protected function findProducts(): array { - $query = $this->_em->createQuery('SELECT p, c FROM Doctrine\Tests\Models\ECommerce\ECommerceProduct p LEFT JOIN p.categories c ORDER BY p.id, c.id'); - //$query->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true); + $query = $this->_em->createQuery('SELECT p, c FROM Doctrine\Tests\Models\ECommerce\ECommerceProduct p LEFT JOIN p.categories c ORDER BY p.id, c.id'); $result = $query->getResult(); self::assertCount(2, $result); $cats1 = $result[0]->getCategories(); @@ -126,8 +125,7 @@ protected function findProducts(): array /** @psalm-return list */ protected function findCategories(): array { - $query = $this->_em->createQuery('SELECT c, p FROM Doctrine\Tests\Models\ECommerce\ECommerceCategory c LEFT JOIN c.products p ORDER BY c.id, p.id'); - //$query->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true); + $query = $this->_em->createQuery('SELECT c, p FROM Doctrine\Tests\Models\ECommerce\ECommerceCategory c LEFT JOIN c.products p ORDER BY c.id, p.id'); $result = $query->getResult(); self::assertCount(2, $result); self::assertInstanceOf(ECommerceCategory::class, $result[0]); diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6362Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6362Test.php index f756ce5e5db..b185a4b430b 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6362Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH6362Test.php @@ -15,7 +15,6 @@ use Doctrine\ORM\Mapping\InheritanceType; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; -use Doctrine\ORM\Query; use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\OrmFunctionalTestCase; @@ -79,7 +78,7 @@ public function testInheritanceJoinAlias(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->_em); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertInstanceOf(GH6362Start::class, $result[0]['base']); self::assertInstanceOf(GH6362Child::class, $result[1][0]); diff --git a/tests/Doctrine/Tests/ORM/Hydration/ObjectHydratorTest.php b/tests/Doctrine/Tests/ORM/Hydration/ObjectHydratorTest.php index 8352ddc46bd..37c36d18a8e 100644 --- a/tests/Doctrine/Tests/ORM/Hydration/ObjectHydratorTest.php +++ b/tests/Doctrine/Tests/ORM/Hydration/ObjectHydratorTest.php @@ -8,7 +8,6 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Proxy\ProxyFactory; -use Doctrine\ORM\Query; use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\Models\CMS\CmsAddress; @@ -65,7 +64,7 @@ public static function provideDataForProductEntityResult(): array } /** - * SELECT PARTIAL u.{id,name} + * SELECT u * FROM Doctrine\Tests\Models\CMS\CmsUser u */ public function testSimpleEntityQuery(): void @@ -89,7 +88,7 @@ public function testSimpleEntityQuery(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -104,7 +103,7 @@ public function testSimpleEntityQuery(): void } /** - * SELECT PARTIAL u.{id,name} AS user + * SELECT u AS user * FROM Doctrine\Tests\Models\CMS\CmsUser u */ public function testSimpleEntityQueryWithAliasedUserEntity(): void @@ -128,7 +127,7 @@ public function testSimpleEntityQueryWithAliasedUserEntity(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -146,7 +145,7 @@ public function testSimpleEntityQueryWithAliasedUserEntity(): void } /** - * SELECT PARTIAL u.{id, name}, PARTIAL a.{id, topic} + * SELECT u, a * FROM Doctrine\Tests\Models\CMS\CmsUser u, Doctrine\Tests\Models\CMS\CmsArticle a */ public function testSimpleMultipleRootEntityQuery(): void @@ -177,7 +176,7 @@ public function testSimpleMultipleRootEntityQuery(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(4, count($result)); @@ -200,7 +199,7 @@ public function testSimpleMultipleRootEntityQuery(): void } /** - * SELECT PARTIAL u.{id, name} AS user, PARTIAL a.{id, topic} + * SELECT u AS user, a * FROM Doctrine\Tests\Models\CMS\CmsUser u, Doctrine\Tests\Models\CMS\CmsArticle a */ public function testSimpleMultipleRootEntityQueryWithAliasedUserEntity(): void @@ -231,7 +230,7 @@ public function testSimpleMultipleRootEntityQueryWithAliasedUserEntity(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(4, count($result)); @@ -261,7 +260,7 @@ public function testSimpleMultipleRootEntityQueryWithAliasedUserEntity(): void } /** - * SELECT PARTIAL u.{id, name}, PARTIAL a.{id, topic} AS article + * SELECT u, a AS article * FROM Doctrine\Tests\Models\CMS\CmsUser u, Doctrine\Tests\Models\CMS\CmsArticle a */ public function testSimpleMultipleRootEntityQueryWithAliasedArticleEntity(): void @@ -292,7 +291,7 @@ public function testSimpleMultipleRootEntityQueryWithAliasedArticleEntity(): voi $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(4, count($result)); @@ -322,7 +321,7 @@ public function testSimpleMultipleRootEntityQueryWithAliasedArticleEntity(): voi } /** - * SELECT PARTIAL u.{id, name} AS user, PARTIAL a.{id, topic} AS article + * SELECT u AS user, a AS article * FROM Doctrine\Tests\Models\CMS\CmsUser u, Doctrine\Tests\Models\CMS\CmsArticle a */ public function testSimpleMultipleRootEntityQueryWithAliasedEntities(): void @@ -353,7 +352,7 @@ public function testSimpleMultipleRootEntityQueryWithAliasedEntities(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(4, count($result)); @@ -383,7 +382,7 @@ public function testSimpleMultipleRootEntityQueryWithAliasedEntities(): void } /** - * SELECT PARTIAL u.{id, status}, COUNT(p.phonenumber) numPhones + * SELECT u, COUNT(p.phonenumber) numPhones * FROM User u * JOIN u.phonenumbers p * GROUP BY u.id @@ -415,7 +414,7 @@ public function testMixedQueryNormalJoin($userEntityKey): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -433,7 +432,7 @@ public function testMixedQueryNormalJoin($userEntityKey): void } /** - * SELECT PARTIAL u.{id, status}, PARTIAL p.{phonenumber}, UPPER(u.name) nameUpper + * SELECT u, p, UPPER(u.name) nameUpper * FROM Doctrine\Tests\Models\CMS\CmsUser u * JOIN u.phonenumbers p * @@ -479,7 +478,7 @@ public function testMixedQueryFetchJoin($userEntityKey): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -559,7 +558,7 @@ public function testMixedQueryFetchJoinCustomIndex($userEntityKey): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -673,7 +672,7 @@ public function testMixedQueryMultipleFetchJoin($userEntityKey): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -803,7 +802,7 @@ public function testMixedQueryMultipleDeepMixedFetchJoin($userEntityKey): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -916,7 +915,7 @@ public function testEntityQueryCustomResultSetOrder(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -936,7 +935,7 @@ public function testEntityQueryCustomResultSetOrder(): void } /** - * SELECT PARTIAL u.{id,name} + * SELECT u * FROM Doctrine\Tests\Models\CMS\CmsUser u * * @group DDC-644 @@ -959,7 +958,7 @@ public function testSkipUnknownColumns(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(1, count($result)); self::assertInstanceOf(CmsUser::class, $result[0]); @@ -992,7 +991,7 @@ public function testScalarQueryWithoutResultVariables($userEntityKey): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -1104,7 +1103,7 @@ public function testCreatesProxyForLazyLoadingWithForeignKeysWithAliasedProductE } /** - * SELECT PARTIAL u.{id, status}, PARTIAL a.{id, topic}, PARTIAL c.{id, topic} + * SELECT u, a, c * FROM Doctrine\Tests\Models\CMS\CmsUser u * LEFT JOIN u.articles a * LEFT JOIN a.comments c @@ -1155,7 +1154,7 @@ public function testChainedJoinWithEmptyCollections(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -1167,7 +1166,7 @@ public function testChainedJoinWithEmptyCollections(): void } /** - * SELECT PARTIAL u.{id, status} AS user, PARTIAL a.{id, topic}, PARTIAL c.{id, topic} + * SELECT u AS user, a, c * FROM Doctrine\Tests\Models\CMS\CmsUser u * LEFT JOIN u.articles a * LEFT JOIN a.comments c @@ -1218,7 +1217,7 @@ public function testChainedJoinWithEmptyCollectionsWithAliasedUserEntity(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -1233,7 +1232,7 @@ public function testChainedJoinWithEmptyCollectionsWithAliasedUserEntity(): void } /** - * SELECT PARTIAL u.{id, name} + * SELECT u * FROM Doctrine\Tests\Models\CMS\CmsUser u */ public function testResultIteration(): void @@ -1259,8 +1258,7 @@ public function testResultIteration(): void $iterableResult = $hydrator->iterate( ArrayResultFactory::createFromArray($resultSet), - $rsm, - [Query::HINT_FORCE_PARTIAL_LOAD => true] + $rsm ); $rowNum = 0; @@ -1283,8 +1281,7 @@ public function testResultIteration(): void $iterableResult = $hydrator->toIterable( ArrayResultFactory::createFromArray($resultSet), - $rsm, - [Query::HINT_FORCE_PARTIAL_LOAD => true] + $rsm ); $rowNum = 0; @@ -1308,7 +1305,7 @@ public function testResultIteration(): void } /** - * SELECT PARTIAL u.{id, name} + * SELECT u * FROM Doctrine\Tests\Models\CMS\CmsUser u */ public function testResultIterationWithAliasedUserEntity(): void @@ -1334,8 +1331,7 @@ public function testResultIterationWithAliasedUserEntity(): void $rowNum = 0; $iterableResult = $hydrator->iterate( ArrayResultFactory::createFromArray($resultSet), - $rsm, - [Query::HINT_FORCE_PARTIAL_LOAD => true] + $rsm ); while (($row = $iterableResult->next()) !== false) { @@ -1360,8 +1356,7 @@ public function testResultIterationWithAliasedUserEntity(): void $rowNum = 0; $iterableResult = $hydrator->toIterable( ArrayResultFactory::createFromArray($resultSet), - $rsm, - [Query::HINT_FORCE_PARTIAL_LOAD => true] + $rsm ); foreach ($iterableResult as $row) { @@ -1388,7 +1383,7 @@ public function testResultIterationWithAliasedUserEntity(): void /** * Checks if multiple joined multiple-valued collections is hydrated correctly. * - * SELECT PARTIAL u.{id, status}, PARTIAL g.{id, name}, PARTIAL p.{phonenumber} + * SELECT u, g, p * FROM Doctrine\Tests\Models\CMS\CmsUser u * * @group DDC-809 @@ -1495,7 +1490,7 @@ public function testManyToManyHydration(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -1511,7 +1506,7 @@ public function testManyToManyHydration(): void /** * Checks if multiple joined multiple-valued collections is hydrated correctly. * - * SELECT PARTIAL u.{id, status} As user, PARTIAL g.{id, name}, PARTIAL p.{phonenumber} + * SELECT u As user, g, p * FROM Doctrine\Tests\Models\CMS\CmsUser u * * @group DDC-809 @@ -1618,7 +1613,7 @@ public function testManyToManyHydrationWithAliasedUserEntity(): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -1635,7 +1630,7 @@ public function testManyToManyHydrationWithAliasedUserEntity(): void } /** - * SELECT PARTIAL u.{id, status}, UPPER(u.name) as nameUpper + * SELECT u, UPPER(u.name) as nameUpper * FROM Doctrine\Tests\Models\CMS\CmsUser u * * @group DDC-1358 @@ -1676,7 +1671,7 @@ public function testMissingIdForRootEntity($userEntityKey): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(4, count($result), 'Should hydrate four results.'); @@ -1693,7 +1688,7 @@ public function testMissingIdForRootEntity($userEntityKey): void } /** - * SELECT PARTIAL u.{id, status}, PARTIAL p.{phonenumber}, UPPER(u.name) AS nameUpper + * SELECT u, p, UPPER(u.name) AS nameUpper * FROM Doctrine\Tests\Models\CMS\CmsUser u * LEFT JOIN u.phonenumbers u * @@ -1746,7 +1741,7 @@ public function testMissingIdForCollectionValuedChildEntity($userEntityKey): voi $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -1755,7 +1750,7 @@ public function testMissingIdForCollectionValuedChildEntity($userEntityKey): voi } /** - * SELECT PARTIAL u.{id, status}, PARTIAL a.{id, city}, UPPER(u.name) AS nameUpper + * SELECT u, a, UPPER(u.name) AS nameUpper * FROM Doctrine\Tests\Models\CMS\CmsUser u * JOIN u.address a * @@ -1800,7 +1795,7 @@ public function testMissingIdForSingleValuedChildEntity($userEntityKey): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -1809,7 +1804,7 @@ public function testMissingIdForSingleValuedChildEntity($userEntityKey): void } /** - * SELECT PARTIAL u.{id, status}, UPPER(u.name) AS nameUpper + * SELECT u, UPPER(u.name) AS nameUpper * FROM Doctrine\Tests\Models\CMS\CmsUser u * INDEX BY u.id * @@ -1842,7 +1837,7 @@ public function testIndexByAndMixedResult($userEntityKey): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals(2, count($result)); @@ -1876,7 +1871,7 @@ public function testIndexByScalarsOnly($userEntityKey): void $stmt = ArrayResultFactory::createFromArray($resultSet); $hydrator = new ObjectHydrator($this->entityManager); - $result = $hydrator->hydrateAll($stmt, $rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + $result = $hydrator->hydrateAll($stmt, $rsm); self::assertEquals( [ diff --git a/tests/Doctrine/Tests/ORM/Query/LanguageRecognitionTest.php b/tests/Doctrine/Tests/ORM/Query/LanguageRecognitionTest.php index 7695bd60054..4d1972593f3 100644 --- a/tests/Doctrine/Tests/ORM/Query/LanguageRecognitionTest.php +++ b/tests/Doctrine/Tests/ORM/Query/LanguageRecognitionTest.php @@ -42,7 +42,6 @@ public function assertInvalidDQL(string $dql): void public function parseDql(string $dql, array $hints = []): ParserResult { $query = $this->entityManager->createQuery($dql); - $query->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true); $query->setDQL($dql); foreach ($hints as $key => $value) { From 55d477dc507a52675c5a61267a00d389c71182f4 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Jun 2023 17:47:09 +0200 Subject: [PATCH 26/72] Fix unserialize() errors when running tests on PHP 8.3 (#10803) --- .../Tests/ORM/Functional/ParserResultSerializationTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Doctrine/Tests/ORM/Functional/ParserResultSerializationTest.php b/tests/Doctrine/Tests/ORM/Functional/ParserResultSerializationTest.php index f5718715e2a..a9d73fc7c98 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ParserResultSerializationTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ParserResultSerializationTest.php @@ -13,6 +13,7 @@ use ReflectionMethod; use function file_get_contents; +use function rtrim; use function serialize; use function unserialize; @@ -56,8 +57,8 @@ public function testUnserializeSingleSelectResult(string $serialized): void /** @return Generator */ public static function provideSerializedSingleSelectResults(): Generator { - yield '2.14.3' => [file_get_contents(__DIR__ . '/ParserResults/single_select_2_14_3.txt')]; - yield '2.15.0' => [file_get_contents(__DIR__ . '/ParserResults/single_select_2_15_0.txt')]; + yield '2.14.3' => [rtrim(file_get_contents(__DIR__ . '/ParserResults/single_select_2_14_3.txt'), "\n")]; + yield '2.15.0' => [rtrim(file_get_contents(__DIR__ . '/ParserResults/single_select_2_15_0.txt'), "\n")]; } private static function parseQuery(Query $query): ParserResult From c9c5157fda047d66abfd381f82b18d0c42f6f435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 27 Jun 2023 22:58:40 +0200 Subject: [PATCH 27/72] Follow recommendation about multiline type Apparently, there is consensus about multiline types between: - PHPStan - Psalm - Slevomat Coding Standard See https://github.com/slevomat/coding-standard/issues/1586#issuecomment-1610195706 Using parenthesis is less ambiguous, it makes it clear to the parser where the type begins and where it ends. The change has a positive impact on the Psalm baseline, showing that psalm-return annotation was not really understood previously. --- lib/Doctrine/ORM/Query/Parser.php | 5 ++--- psalm-baseline.xml | 9 --------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/lib/Doctrine/ORM/Query/Parser.php b/lib/Doctrine/ORM/Query/Parser.php index 4cfe233a1ed..7e69b4865d0 100644 --- a/lib/Doctrine/ORM/Query/Parser.php +++ b/lib/Doctrine/ORM/Query/Parser.php @@ -2566,8 +2566,7 @@ public function ConditionalPrimary() * EmptyCollectionComparisonExpression | CollectionMemberExpression | * InstanceOfExpression * - * @return AST\Node - * @psalm-return AST\BetweenExpression| + * @return (AST\BetweenExpression| * AST\CollectionMemberExpression| * AST\ComparisonExpression| * AST\EmptyCollectionComparisonExpression| @@ -2575,7 +2574,7 @@ public function ConditionalPrimary() * AST\InExpression| * AST\InstanceOfExpression| * AST\LikeExpression| - * AST\NullComparisonExpression + * AST\NullComparisonExpression) */ public function SimpleConditionalExpression() { diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 249630341bb..e24083e4046 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -2008,19 +2008,10 @@ $factors[0] $primary $terms[0] - CollectionMemberExpression()]]> - ComparisonExpression()]]> - EmptyCollectionComparisonExpression()]]> - ExistsExpression()]]> - InExpression()]]> - InstanceOfExpression()]]> - LikeExpression()]]> - NullComparisonExpression()]]> AST\ArithmeticFactor AST\ArithmeticTerm - AST\BetweenExpression| AST\SimpleArithmeticExpression|AST\ArithmeticTerm From 7fc359c2bb7d92ac0c4088986307fd793141e458 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 28 Jun 2023 14:46:22 +0200 Subject: [PATCH 28/72] Avoid unnecessarily passing entity lists into executeDeletions/executeInserts --- lib/Doctrine/ORM/UnitOfWork.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index f9476fe4319..821d73a7a34 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -439,7 +439,7 @@ public function commit($entity = null) // into account (new entities referring to other new entities), since all other types (entities // with updates or scheduled deletions) are currently not a problem, since they are already // in the database. - $this->executeInserts($this->computeInsertExecutionOrder()); + $this->executeInserts(); } if ($this->entityUpdates) { @@ -464,7 +464,7 @@ public function commit($entity = null) // Entity deletions come last. Their order only needs to take care of other deletions // (first delete entities depending upon others, before deleting depended-upon entities). if ($this->entityDeletions) { - $this->executeDeletions($this->computeDeleteExecutionOrder()); + $this->executeDeletions(); } // Commit failed silently @@ -1144,12 +1144,12 @@ public function recomputeSingleEntityChangeSet(ClassMetadata $class, $entity) } /** - * Executes entity insertions in the given order - * - * @param list $entities + * Executes entity insertions */ - private function executeInserts(array $entities): void + private function executeInserts(): void { + $entities = $this->computeInsertExecutionOrder(); + foreach ($entities as $entity) { $oid = spl_object_id($entity); $class = $this->em->getClassMetadata(get_class($entity)); @@ -1251,11 +1251,11 @@ private function executeUpdates(): void /** * Executes all entity deletions - * - * @param list $entities */ - private function executeDeletions(array $entities): void + private function executeDeletions(): void { + $entities = $this->computeDeleteExecutionOrder(); + foreach ($entities as $entity) { $oid = spl_object_id($entity); $class = $this->em->getClassMetadata(get_class($entity)); From 5afe9b80a8b78ef3f79178a4bb5213e4edce8868 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 28 Jun 2023 15:10:25 +0200 Subject: [PATCH 29/72] Move three "Ticket/"-style tests to the right namespace --- .../Tests/ORM/Functional/{ => Ticket}/GH10747Test.php | 4 ++-- .../Tests/ORM/Functional/{ => Ticket}/GH10752Test.php | 2 +- .../Doctrine/Tests/ORM/Functional/{ => Ticket}/GH5988Test.php | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename tests/Doctrine/Tests/ORM/Functional/{ => Ticket}/GH10747Test.php (96%) rename tests/Doctrine/Tests/ORM/Functional/{ => Ticket}/GH10752Test.php (98%) rename tests/Doctrine/Tests/ORM/Functional/{ => Ticket}/GH5988Test.php (95%) diff --git a/tests/Doctrine/Tests/ORM/Functional/GH10747Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10747Test.php similarity index 96% rename from tests/Doctrine/Tests/ORM/Functional/GH10747Test.php rename to tests/Doctrine/Tests/ORM/Functional/Ticket/GH10747Test.php index 019449a4164..198341af789 100644 --- a/tests/Doctrine/Tests/ORM/Functional/GH10747Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10747Test.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Doctrine\Tests\ORM\Functional; +namespace Doctrine\Tests\ORM\Functional\Ticket; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -93,7 +93,7 @@ class GH10747Article { /** * @Id - * @Column(type="Doctrine\Tests\ORM\Functional\GH10747CustomIdObjectHashType") + * @Column(type="Doctrine\Tests\ORM\Functional\Ticket\GH10747CustomIdObjectHashType") * @var CustomIdObject */ public $id; diff --git a/tests/Doctrine/Tests/ORM/Functional/GH10752Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10752Test.php similarity index 98% rename from tests/Doctrine/Tests/ORM/Functional/GH10752Test.php rename to tests/Doctrine/Tests/ORM/Functional/Ticket/GH10752Test.php index c90d1995001..afeefa4121b 100644 --- a/tests/Doctrine/Tests/ORM/Functional/GH10752Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10752Test.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Doctrine\Tests\ORM\Functional; +namespace Doctrine\Tests\ORM\Functional\Ticket; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; diff --git a/tests/Doctrine/Tests/ORM/Functional/GH5988Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH5988Test.php similarity index 95% rename from tests/Doctrine/Tests/ORM/Functional/GH5988Test.php rename to tests/Doctrine/Tests/ORM/Functional/Ticket/GH5988Test.php index bf47d315b42..4e731b54353 100644 --- a/tests/Doctrine/Tests/ORM/Functional/GH5988Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH5988Test.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Doctrine\Tests\ORM\Functional; +namespace Doctrine\Tests\ORM\Functional\Ticket; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type as DBALType; @@ -106,7 +106,7 @@ abstract class GH5988CustomIdObjectTypeParent { /** * @Id - * @Column(type="Doctrine\Tests\ORM\Functional\GH5988CustomIdObjectHashType", length=255) + * @Column(type="Doctrine\Tests\ORM\Functional\Ticket\GH5988CustomIdObjectHashType", length=255) * @var CustomIdObject */ public $id; From 2df1071e7a3dd15663d5d73e700e720ae6e1f66f Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 28 Jun 2023 17:10:20 +0200 Subject: [PATCH 30/72] Remove references to the temporary branch in workflow definitions --- .github/workflows/coding-standards.yml | 1 - .github/workflows/continuous-integration.yml | 1 - .github/workflows/phpbench.yml | 1 - .github/workflows/static-analysis.yml | 1 - 4 files changed, 4 deletions(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 38ed3b07886..1ce1a90c1b0 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -4,7 +4,6 @@ on: pull_request: branches: - "*.x" - - entity-level-commit-order paths: - .github/workflows/coding-standards.yml - bin/** diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index c0a1f991aa8..7d5f9543c54 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -4,7 +4,6 @@ on: pull_request: branches: - "*.x" - - entity-level-commit-order paths: - .github/workflows/continuous-integration.yml - ci/** diff --git a/.github/workflows/phpbench.yml b/.github/workflows/phpbench.yml index 9b3f7ebd54c..5fcc3f53edb 100644 --- a/.github/workflows/phpbench.yml +++ b/.github/workflows/phpbench.yml @@ -5,7 +5,6 @@ on: pull_request: branches: - "*.x" - - entity-level-commit-order paths: - .github/workflows/phpbench.yml - composer.* diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 2ec0424d488..2e9263b7a57 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -4,7 +4,6 @@ on: pull_request: branches: - "*.x" - - entity-level-commit-order paths: - .github/workflows/static-analysis.yml - composer.* From 01a14327d2404885897961851659b467ec966d74 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Thu, 22 Jun 2023 11:46:43 +0200 Subject: [PATCH 31/72] Add a safeguard against multiple objects competing for the same identity map entry While trying to understand #3037, I found that it may happen that we have more entries in `\Doctrine\ORM\UnitOfWork::$entityIdentifiers` than in `\Doctrine\ORM\UnitOfWork::$identityMap`. The former is a mapping from `spl_object_id` values to ID hashes, the latter an array first of entity class names and then from ID hash to entity object instances. (Basically, "ID hash" is a concatenation of all field values making up the `@Id` for a given entity.) This means that at some point, we must have _different_ objects representing the same entity, or at least over time different objects are used for the same entity without the UoW properly updating its `::$entityIdentifiers` structure. I don't think it makes sense to overwrite an entity in the identity map, since that means a currently `MANAGED` entity is replaced with something else. If it makes sense at all to _replace_ an entity, that should happen through dedicated management methods to first detach the old entity before persisting, merging or otherwise adding the new one. This way we could make sure the internal structures remain consistent. --- lib/Doctrine/ORM/UnitOfWork.php | 54 ++++++++++--------- .../ORM/Functional/BasicFunctionalTest.php | 30 +++++++++++ .../Functional/ProxiesLikeEntitiesTest.php | 2 +- .../ORM/Functional/Ticket/DDC1238Test.php | 2 +- .../ORM/Functional/Ticket/GH7869Test.php | 2 +- tests/Doctrine/Tests/ORM/UnitOfWorkTest.php | 22 ++++++++ 6 files changed, 85 insertions(+), 27 deletions(-) diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index 37b1412f195..58bb27a8651 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -1591,6 +1591,30 @@ public function addToIdentityMap($entity) $className = $classMetadata->rootEntityName; if (isset($this->identityMap[$className][$idHash])) { + if ($this->identityMap[$className][$idHash] !== $entity) { + throw new RuntimeException(sprintf( + <<<'EXCEPTION' +While adding an entity of class %s with an ID hash of "%s" to the identity map, +another object of class %s was already present for the same ID. This exception +is a safeguard against an internal inconsistency - IDs should uniquely map to +entity object instances. This problem may occur if: + +- you use application-provided IDs and reuse ID values; +- database-provided IDs are reassigned after truncating the database without + clearing the EntityManager; +- you might have been using EntityManager#getReference() to create a reference + for a nonexistent ID that was subsequently (by the RDBMS) assigned to another + entity. + +Otherwise, it might be an ORM-internal inconsistency, please report it. +EXCEPTION + , + get_class($entity), + $idHash, + get_class($this->identityMap[$className][$idHash]) + )); + } + return false; } @@ -2811,25 +2835,20 @@ public function createEntity($className, array $data, &$hints = []) } $this->originalEntityData[$oid] = $data; + + if ($entity instanceof NotifyPropertyChanged) { + $entity->addPropertyChangedListener($this); + } } else { $entity = $this->newInstance($class); $oid = spl_object_id($entity); - - $this->entityIdentifiers[$oid] = $id; - $this->entityStates[$oid] = self::STATE_MANAGED; - $this->originalEntityData[$oid] = $data; - - $this->identityMap[$class->rootEntityName][$idHash] = $entity; + $this->registerManaged($entity, $id, $data); 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); @@ -2987,20 +3006,7 @@ public function createEntity($className, array $data, &$hints = []) break; } - // PERF: Inlined & optimized code from UnitOfWork#registerManaged() - $newValueOid = spl_object_id($newValue); - $this->entityIdentifiers[$newValueOid] = $associatedId; - $this->identityMap[$targetClass->rootEntityName][$relatedIdHash] = $newValue; - - if ( - $newValue instanceof NotifyPropertyChanged && - ( ! $newValue instanceof Proxy || $newValue->__isInitialized()) - ) { - $newValue->addPropertyChangedListener($this); - } - - $this->entityStates[$newValueOid] = self::STATE_MANAGED; - // make sure that when an proxy is then finally loaded, $this->originalEntityData is set also! + $this->registerManaged($newValue, $associatedId, []); break; } diff --git a/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php b/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php index 8689dd5735f..f2681258018 100644 --- a/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php @@ -19,6 +19,7 @@ use Doctrine\Tests\Models\CMS\CmsUser; use Doctrine\Tests\OrmFunctionalTestCase; use InvalidArgumentException; +use RuntimeException; use function get_class; @@ -1291,4 +1292,33 @@ public function testWrongAssociationInstance(): void $this->_em->flush(); } + + public function testItThrowsWhenReferenceUsesIdAssignedByDatabase(): void + { + $user = new CmsUser(); + $user->name = 'test'; + $user->username = 'test'; + $this->_em->persist($user); + $this->_em->flush(); + + // Obtain a reference object for the next ID. This is a user error - references + // should be fetched only for existing IDs + $ref = $this->_em->getReference(CmsUser::class, $user->id + 1); + + $user2 = new CmsUser(); + $user2->name = 'test2'; + $user2->username = 'test2'; + + // Now the database will assign an ID to the $user2 entity, but that place + // in the identity map is already taken by user error. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/another object .* was already present for the same ID/'); + + // depending on ID generation strategy, the ID may be asssigned already here + // and the entity be put in the identity map + $this->_em->persist($user2); + + // post insert IDs will be assigned during flush + $this->_em->flush(); + } } diff --git a/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php b/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php index c15306efc3a..24b61d8654e 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php @@ -95,7 +95,7 @@ public function testEntityWithIdentifier(): void */ public function testProxyAsDqlParameterPersist(): void { - $proxy = $this->_em->getProxyFactory()->getProxy(CmsUser::class, ['id' => $this->user->getId()]); + $proxy = $this->_em->getReference(CmsUser::class, ['id' => $this->user->getId()]); $proxy->id = $this->user->getId(); $result = $this ->_em diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1238Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1238Test.php index 082b40f1967..bdb1d70231a 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1238Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1238Test.php @@ -56,7 +56,7 @@ public function testIssueProxyClear(): void $user2 = $this->_em->getReference(DDC1238User::class, $userId); - $user->__load(); + //$user->__load(); self::assertIsInt($user->getId(), 'Even if a proxy is detached, it should still have an identifier'); diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7869Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7869Test.php index 11a5eec69e2..534bb6c1c37 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7869Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH7869Test.php @@ -55,7 +55,7 @@ public function getClassMetadata($className): ClassMetadata $uow->clear(); $uow->triggerEagerLoads(); - self::assertSame(2, $em->getClassMetadataCalls); + self::assertSame(4, $em->getClassMetadataCalls); } } diff --git a/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php b/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php index 5e5fc29b7f9..e47af9d57df 100644 --- a/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php @@ -41,6 +41,7 @@ use Doctrine\Tests\OrmTestCase; use Doctrine\Tests\PHPUnitCompatibility\MockBuilderCompatibilityTools; use PHPUnit\Framework\MockObject\MockObject; +use RuntimeException; use stdClass; use function assert; @@ -108,6 +109,8 @@ protected function setUp(): void $driverConnection = $this->createMock(Driver\Connection::class); $driverConnection->method('prepare') ->willReturn($driverStatement); + $driverConnection->method('lastInsertId') + ->willReturnOnConsecutiveCalls(1, 2, 3, 4, 5, 6); $driver = $this->createMock(Driver::class); $driver->method('getDatabasePlatform') @@ -923,6 +926,25 @@ public function testRemovedEntityIsRemovedFromOneToManyCollection(): void self::assertFalse($user->phonenumbers->isDirty()); self::assertEmpty($user->phonenumbers->getSnapshot()); } + + public function testItThrowsWhenApplicationProvidedIdsCollide(): void + { + // We're using application-provided IDs and assign the same ID twice + // Note this is about colliding IDs in the identity map in memory. + // Duplicate database-level IDs would be spotted when the EM is flushed. + + $phone1 = new CmsPhonenumber(); + $phone1->phonenumber = '1234'; + $this->_unitOfWork->persist($phone1); + + $phone2 = new CmsPhonenumber(); + $phone2->phonenumber = '1234'; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/another object .* was already present for the same ID/'); + + $this->_unitOfWork->persist($phone2); + } } /** @Entity */ From e9b6fd89a4874906ec033096b34ddfc8dba12844 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 28 Jun 2023 16:16:15 +0200 Subject: [PATCH 32/72] Add test to show why delete-before-insert may be challenging There are a few requests (#5742, #5368, #5109, #6776) that ask to change the order of operations in the UnitOfWork to perform "deletes before inserts", or where such a switch appears to solve a reported problem. I don't want to say that this is not doable. But this PR at least adds two tricky examples where INSERTs need to be done before an UPDATE can refer to new database rows; and where the UPDATE needs to happen to release foreign key references to other entities before those can be DELETEd. So, at least as long as all operations of a certain type are to be executed in blocks, this example allows no other order of operations than the current one. --- .../ORM/Functional/Ticket/GH5742Test.php | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH5742Test.php diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH5742Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH5742Test.php new file mode 100644 index 00000000000..ba62f2531bc --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH5742Test.php @@ -0,0 +1,158 @@ +createSchemaForModels( + GH5742Person::class, + GH5742Toothbrush::class, + GH5742ToothpasteBrand::class + ); + } + + public function testUpdateOneToOneToNewEntityBeforePreviousEntityCanBeRemoved(): void + { + $person = new GH5742Person(); + $oldToothbrush = new GH5742Toothbrush(); + $person->toothbrush = $oldToothbrush; + + $this->_em->persist($person); + $this->_em->persist($oldToothbrush); + $this->_em->flush(); + + $oldToothbrushId = $oldToothbrush->id; + + $newToothbrush = new GH5742Toothbrush(); + $person->toothbrush = $newToothbrush; + + $this->_em->remove($oldToothbrush); + $this->_em->persist($newToothbrush); + + // The flush operation will have to make sure the new toothbrush + // has been written to the database + // _before_ the person can be updated to refer to it. + // Likewise, the update must have happened _before_ the old + // toothbrush can be removed (non-nullable FK constraint). + + $this->_em->flush(); + + $this->_em->clear(); + self::assertSame($newToothbrush->id, $this->_em->find(GH5742Person::class, $person->id)->toothbrush->id); + self::assertNull($this->_em->find(GH5742Toothbrush::class, $oldToothbrushId)); + } + + public function testManyToManyCollectionUpdateBeforeRemoval(): void + { + $person = new GH5742Person(); + $person->toothbrush = new GH5742Toothbrush(); // to satisfy not-null constraint + $this->_em->persist($person); + + $oldMice = new GH5742ToothpasteBrand(); + $this->_em->persist($oldMice); + + $person->preferredBrands->set(1, $oldMice); + $this->_em->flush(); + + $oldBrandId = $oldMice->id; + + $newSpice = new GH5742ToothpasteBrand(); + $this->_em->persist($newSpice); + + $person->preferredBrands->set(1, $newSpice); + + $this->_em->remove($oldMice); + + // The flush operation will have to make sure the new brand + // has been written to the database _before_ it can be referred + // to from the m2m join table. + // Likewise, the old join table entry must have been removed + // _before_ the old brand can be removed. + + $this->_em->flush(); + + $this->_em->clear(); + self::assertCount(1, $this->_em->find(GH5742Person::class, $person->id)->preferredBrands); + self::assertNull($this->_em->find(GH5742ToothpasteBrand::class, $oldBrandId)); + } +} + +/** + * @ORM\Entity + */ +class GH5742Person +{ + /** + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + * @ORM\Column(type="integer") + * + * @var int + */ + public $id; + + /** + * @ORM\OneToOne(targetEntity="GH5742Toothbrush", cascade={"persist"}) + * @ORM\JoinColumn(nullable=false) + * + * @var GH5742Toothbrush + */ + public $toothbrush; + + /** + * @ORM\ManyToMany(targetEntity="GH5742ToothpasteBrand") + * @ORM\JoinTable(name="gh5742person_gh5742toothpastebrand", + * joinColumns={@ORM\JoinColumn(name="person_id", referencedColumnName="id", onDelete="CASCADE")}, + * inverseJoinColumns={@ORM\JoinColumn(name="brand_id", referencedColumnName="id")} + * ) + * + * @var Collection + */ + public $preferredBrands; + + public function __construct() + { + $this->preferredBrands = new ArrayCollection(); + } +} + +/** + * @ORM\Entity + */ +class GH5742Toothbrush +{ + /** + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + * @ORM\Column(type="integer") + * + * @var int + */ + public $id; +} + +/** + * @ORM\Entity + */ +class GH5742ToothpasteBrand +{ + /** + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + * @ORM\Column(type="integer") + * + * @var int + */ + public $id; +} From 0877ecbe56c728aa307ee2b878d024ebec101f2c Mon Sep 17 00:00:00 2001 From: Grzegorz K <1397756+Greg0@users.noreply.github.com> Date: Mon, 3 Jul 2023 15:15:08 +0200 Subject: [PATCH 33/72] Treat id field proprites same as regular field --- lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php index 47a70d62c39..898d86f32bc 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php @@ -376,30 +376,8 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad continue; } - $mapping = [ - 'id' => true, - 'fieldName' => (string) $idElement['name'], - ]; - - if (isset($idElement['type'])) { - $mapping['type'] = (string) $idElement['type']; - } - - if (isset($idElement['length'])) { - $mapping['length'] = (int) $idElement['length']; - } - - if (isset($idElement['column'])) { - $mapping['columnName'] = (string) $idElement['column']; - } - - if (isset($idElement['column-definition'])) { - $mapping['columnDefinition'] = (string) $idElement['column-definition']; - } - - if (isset($idElement->options)) { - $mapping['options'] = $this->parseOptions($idElement->options->children()); - } + $mapping = $this->columnToArray($idElement); + $mapping['id'] = true; $metadata->mapField($mapping); From 8c59828f6cc4e07aa9cd72798d8712b5dd59034a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Mon, 3 Jul 2023 21:46:12 +0200 Subject: [PATCH 34/72] Remove lone dash (#10812) --- docs/en/reference/query-builder.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/en/reference/query-builder.rst b/docs/en/reference/query-builder.rst index 7e3e3fff363..99de6df8d08 100644 --- a/docs/en/reference/query-builder.rst +++ b/docs/en/reference/query-builder.rst @@ -578,8 +578,6 @@ of DQL. It takes 3 parameters: ``$dqlPartName``, ``$dqlPart`` and not (no effect on the ``where`` and ``having`` DQL query parts, which always override all previously defined items) -- - .. code-block:: php Date: Tue, 4 Jul 2023 14:17:14 +0200 Subject: [PATCH 35/72] Un-prefix simple generics like `list<>` and `array<>` ... as suggested in GH review. --- lib/Doctrine/ORM/Internal/TopologicalSort.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/Doctrine/ORM/Internal/TopologicalSort.php b/lib/Doctrine/ORM/Internal/TopologicalSort.php index 35bc43c7269..2bf1624ced8 100644 --- a/lib/Doctrine/ORM/Internal/TopologicalSort.php +++ b/lib/Doctrine/ORM/Internal/TopologicalSort.php @@ -53,7 +53,7 @@ final class TopologicalSort /** * Builds up the result during the DFS. * - * @psalm-var list + * @var list */ private $sortResult = []; @@ -95,9 +95,9 @@ public function addEdge($from, $to, bool $optional): void * Returns a topological sort of all nodes. When we have an edge A->B between two nodes * A and B, then A will be listed before B in the result. * - * @psalm-return list + * @return list */ - public function sort() + public function sort(): array { /* * When possible, keep objects in the result in the same order in which they were added as nodes. From bb21865cba0475d74ada1f487e4c2fef59256ee4 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Tue, 4 Jul 2023 14:22:10 +0200 Subject: [PATCH 36/72] Deprecate classes related to old commit order computation --- UPGRADE.md | 7 +++++++ lib/Doctrine/ORM/Internal/CommitOrder/Edge.php | 9 +++++++++ lib/Doctrine/ORM/Internal/CommitOrder/Vertex.php | 8 ++++++++ lib/Doctrine/ORM/Internal/CommitOrder/VertexState.php | 8 ++++++++ lib/Doctrine/ORM/Internal/CommitOrderCalculator.php | 11 +++++++++++ 5 files changed, 43 insertions(+) diff --git a/UPGRADE.md b/UPGRADE.md index bf2d9b385db..ef703efa09d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,12 @@ # Upgrade to 2.16 +## Deprecated `\Doctrine\ORM\Internal\CommitOrderCalculator` and related classes + +With changes made to the commit order computation, the internal classes +`\Doctrine\ORM\Internal\CommitOrderCalculator`, `\Doctrine\ORM\Internal\CommitOrder\Edge`, +`\Doctrine\ORM\Internal\CommitOrder\Vertex` and `\Doctrine\ORM\Internal\CommitOrder\VertexState` +have been deprecated and will be removed in ORM 3.0. + ## Deprecated returning post insert IDs from `EntityPersister::executeInserts()` Persisters implementing `\Doctrine\ORM\Persisters\Entity\EntityPersister` should no longer diff --git a/lib/Doctrine/ORM/Internal/CommitOrder/Edge.php b/lib/Doctrine/ORM/Internal/CommitOrder/Edge.php index f1457755ee1..dc840c93843 100644 --- a/lib/Doctrine/ORM/Internal/CommitOrder/Edge.php +++ b/lib/Doctrine/ORM/Internal/CommitOrder/Edge.php @@ -4,6 +4,8 @@ namespace Doctrine\ORM\Internal\CommitOrder; +use Doctrine\Deprecations\Deprecation; + /** @internal */ final class Edge { @@ -27,6 +29,13 @@ final class Edge public function __construct(string $from, string $to, int $weight) { + Deprecation::triggerIfCalledFromOutside( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/10547', + 'The %s class is deprecated and will be removed in ORM 3.0', + self::class + ); + $this->from = $from; $this->to = $to; $this->weight = $weight; diff --git a/lib/Doctrine/ORM/Internal/CommitOrder/Vertex.php b/lib/Doctrine/ORM/Internal/CommitOrder/Vertex.php index c4747e032d1..969a75b851a 100644 --- a/lib/Doctrine/ORM/Internal/CommitOrder/Vertex.php +++ b/lib/Doctrine/ORM/Internal/CommitOrder/Vertex.php @@ -4,6 +4,7 @@ namespace Doctrine\ORM\Internal\CommitOrder; +use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\Mapping\ClassMetadata; /** @internal */ @@ -32,6 +33,13 @@ final class Vertex public function __construct(string $hash, ClassMetadata $value) { + Deprecation::triggerIfCalledFromOutside( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/10547', + 'The %s class is deprecated and will be removed in ORM 3.0', + self::class + ); + $this->hash = $hash; $this->value = $value; } diff --git a/lib/Doctrine/ORM/Internal/CommitOrder/VertexState.php b/lib/Doctrine/ORM/Internal/CommitOrder/VertexState.php index 395db58d554..9ace165d20d 100644 --- a/lib/Doctrine/ORM/Internal/CommitOrder/VertexState.php +++ b/lib/Doctrine/ORM/Internal/CommitOrder/VertexState.php @@ -4,6 +4,8 @@ namespace Doctrine\ORM\Internal\CommitOrder; +use Doctrine\Deprecations\Deprecation; + /** @internal */ final class VertexState { @@ -13,5 +15,11 @@ final class VertexState private function __construct() { + Deprecation::triggerIfCalledFromOutside( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/10547', + 'The %s class is deprecated and will be removed in ORM 3.0', + self::class + ); } } diff --git a/lib/Doctrine/ORM/Internal/CommitOrderCalculator.php b/lib/Doctrine/ORM/Internal/CommitOrderCalculator.php index 22ee1ab13bd..d45e65c40b5 100644 --- a/lib/Doctrine/ORM/Internal/CommitOrderCalculator.php +++ b/lib/Doctrine/ORM/Internal/CommitOrderCalculator.php @@ -4,6 +4,7 @@ namespace Doctrine\ORM\Internal; +use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\Internal\CommitOrder\Edge; use Doctrine\ORM\Internal\CommitOrder\Vertex; use Doctrine\ORM\Internal\CommitOrder\VertexState; @@ -45,6 +46,16 @@ class CommitOrderCalculator */ private $sortedNodeList = []; + public function __construct() + { + Deprecation::triggerIfCalledFromOutside( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/10547', + 'The %s class is deprecated and will be removed in ORM 3.0', + self::class + ); + } + /** * Checks for node (vertex) existence in graph. * From a4ecd023491259410b627739d7ffcc48d09a0942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 4 Jul 2023 15:44:53 +0200 Subject: [PATCH 37/72] Introduce new workflow to test docs This allows to check compatibility with phpDocumentor/guides, but also should allow to spot embarassing mistakes in our existing docs. --- .github/workflows/documentation.yml | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/documentation.yml diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 00000000000..f7e42ef018c --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,47 @@ +name: "Documentation" + +on: + pull_request: + branches: + - "*.x" + paths: + - .github/workflows/documentation.yml + - docs/** + push: + branches: + - "*.x" + paths: + - .github/workflows/documentation.yml + - docs/** + +jobs: + validate-with-guides: + name: "Validate documentation with phpDocumentor/guides" + runs-on: "ubuntu-22.04" + + steps: + - name: "Checkout code" + uses: "actions/checkout@v3" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "8.2" + + - name: "Remove existing composer file" + run: "rm composer.json" + + - name: "Require phpdocumentor/guides-cli" + run: "composer require --dev phpdocumentor/guides-cli dev-main@dev --no-update" + + - name: "Configure minimum stability" + run: "composer config minimum-stability dev" + + - name: "Install dependencies with Composer" + uses: "ramsey/composer-install@v2" + with: + dependency-versions: "highest" + + - name: "Run guides-cli" + run: "vendor/bin/guides -vvv --no-progress docs/en /tmp/test 2>&1 | ( ! grep WARNING )" From 1aeab391c74286c94f0793465e6bba0da3059d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 4 Jul 2023 16:48:14 +0200 Subject: [PATCH 38/72] Escape pipes Pipes can be used to define substitutions, it is part of the rst standard. This explains why some of the links in this document are not displayed on the website. See https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#substitution-definitions --- docs/en/index.rst | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/en/index.rst b/docs/en/index.rst index f89c4ec2693..5fd837f8274 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -34,32 +34,32 @@ Mapping Objects onto a Database ------------------------------- * **Mapping**: - :doc:`Objects ` | - :doc:`Associations ` | + :doc:`Objects ` \| + :doc:`Associations ` \| :doc:`Inheritance ` * **Drivers**: - :doc:`Docblock Annotations ` | - :doc:`Attributes ` | - :doc:`XML ` | - :doc:`YAML ` | + :doc:`Docblock Annotations ` \| + :doc:`Attributes ` \| + :doc:`XML ` \| + :doc:`YAML ` \| :doc:`PHP ` Working with Objects -------------------- * **Basic Reference**: - :doc:`Entities ` | - :doc:`Associations ` | + :doc:`Entities ` \| + :doc:`Associations ` \| :doc:`Events ` * **Query Reference**: - :doc:`DQL ` | - :doc:`QueryBuilder ` | + :doc:`DQL ` \| + :doc:`QueryBuilder ` \| :doc:`Native SQL ` * **Internals**: - :doc:`Internals explained ` | + :doc:`Internals explained ` \| :doc:`Associations ` Advanced Topics @@ -102,20 +102,20 @@ Cookbook -------- * **Patterns**: - :doc:`Aggregate Fields ` | - :doc:`Decorator Pattern ` | + :doc:`Aggregate Fields ` \| + :doc:`Decorator Pattern ` \| :doc:`Strategy Pattern ` * **DQL Extension Points**: - :doc:`DQL Custom Walkers ` | + :doc:`DQL Custom Walkers ` \| :doc:`DQL User-Defined-Functions ` * **Implementation**: - :doc:`Array Access ` | - :doc:`Notify ChangeTracking Example ` | - :doc:`Working with DateTime ` | - :doc:`Validation ` | - :doc:`Entities in the Session ` | + :doc:`Array Access ` \| + :doc:`Notify ChangeTracking Example ` \| + :doc:`Working with DateTime ` \| + :doc:`Validation ` \| + :doc:`Entities in the Session ` \| :doc:`Keeping your Modules independent ` * **Hidden Gems** From a157bc3fb3ad4f17765985b22b10cb41d78cc3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 4 Jul 2023 17:05:47 +0200 Subject: [PATCH 39/72] Use internal link --- docs/en/reference/dql-doctrine-query-language.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index 140d558b336..7e64ac0263c 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -1336,8 +1336,8 @@ There are situations when a query you want to execute returns a very large result-set that needs to be processed. All the previously described hydration modes completely load a result-set into memory which might not be feasible with large result sets. See -the `Batch Processing `_ section on details how -to iterate large result sets. +the :doc:`Batch Processing ` section on +details how to iterate large result sets. Functions ~~~~~~~~~ From 4d56711d8c91879ca3383a6aa262158387486f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 4 Jul 2023 17:07:31 +0200 Subject: [PATCH 40/72] Use rst syntax Using underscore for emphasis is not an RST thing, in rst the difference between emphasis and strong emphasis is in the number of asterisks. Right now these underscores are just ignored and displayed on the website. See https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#emphasis --- docs/en/reference/inheritance-mapping.rst | 2 +- docs/en/reference/limitations-and-known-issues.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/reference/inheritance-mapping.rst b/docs/en/reference/inheritance-mapping.rst index 1605cf5d3cd..fc972cc10e1 100644 --- a/docs/en/reference/inheritance-mapping.rst +++ b/docs/en/reference/inheritance-mapping.rst @@ -45,7 +45,7 @@ have to be used. .. warning:: At least when using attributes or annotations to specify your mapping, - it _seems_ as if you could inherit from a base class that is neither + it *seems* as if you could inherit from a base class that is neither an entity nor a mapped superclass, but has properties with mapping configuration on them that would also be used in the inheriting class. diff --git a/docs/en/reference/limitations-and-known-issues.rst b/docs/en/reference/limitations-and-known-issues.rst index cf67c67e3a2..aa0c2920046 100644 --- a/docs/en/reference/limitations-and-known-issues.rst +++ b/docs/en/reference/limitations-and-known-issues.rst @@ -162,7 +162,7 @@ that, some precedence and conflict resolution rules apply. When it comes to loading mapping configuration, the annotation and attribute drivers rely on PHP reflection to inspect class properties including their docblocks. -As long as the results are consistent with what a solution _without_ traits would +As long as the results are consistent with what a solution *without* traits would have produced, this is probably fine. However, to mention known limitations, it is currently not possible to use "class" From 5d2d6642c833a2ca411c2f1120d7d1a10d9962c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 4 Jul 2023 17:13:14 +0200 Subject: [PATCH 41/72] Fix invalid reference syntax Without this change, a hash is displayed for some reason. --- docs/en/reference/installation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/reference/installation.rst b/docs/en/reference/installation.rst index fec83787eff..639a3bfd911 100644 --- a/docs/en/reference/installation.rst +++ b/docs/en/reference/installation.rst @@ -1,4 +1,4 @@ Installation ============ -The installation chapter has moved to :doc:`Installation and Configuration `_. +The installation chapter has moved to :doc:`Installation and Configuration `. From 710937d6f8357a4278f31468bf1da8e55958ebb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 4 Jul 2023 17:25:35 +0200 Subject: [PATCH 42/72] Use correct syntax for references doc was used when it is clearly a ref, plus there was a leading underscore preventing the resolution. --- docs/en/reference/basic-mapping.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/reference/basic-mapping.rst b/docs/en/reference/basic-mapping.rst index 1d8fd856344..6004988a15a 100644 --- a/docs/en/reference/basic-mapping.rst +++ b/docs/en/reference/basic-mapping.rst @@ -460,7 +460,7 @@ Here is the list of possible generation strategies: a new entity is passed to ``EntityManager#persist``. NONE is the same as leaving off the ``#[GeneratedValue]`` entirely. - ``CUSTOM``: With this option, you can use the ``#[CustomIdGenerator]`` attribute. - It will allow you to pass a :doc:`class of your own to generate the identifiers.<_annref_customidgenerator>` + It will allow you to pass a :ref:`class of your own to generate the identifiers.` Sequence Generator ^^^^^^^^^^^^^^^^^^ From b6e7e6d7233927c75a926670c2089765567d0ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 4 Jul 2023 17:31:05 +0200 Subject: [PATCH 43/72] Avoid colon followed by double colon It seems to confuse the guides-cli, if it is even valid. --- docs/en/tutorials/getting-started.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/en/tutorials/getting-started.rst b/docs/en/tutorials/getting-started.rst index 071486ee053..2cd398cb6ad 100644 --- a/docs/en/tutorials/getting-started.rst +++ b/docs/en/tutorials/getting-started.rst @@ -102,8 +102,7 @@ Install Doctrine using the Composer Dependency Management tool, by calling: This will install the packages Doctrine Common, Doctrine DBAL, Doctrine ORM, into the ``vendor`` directory. -Add the following directories: -:: +Add the following directories:: doctrine2-tutorial |-- config From 9dadffe270d9418e2f1bfbeba05d82b43cecc5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 4 Jul 2023 17:42:29 +0200 Subject: [PATCH 44/72] Use absolute references According to the Sphinx docs, when in reference/architecture.rst, a reference to reference/inheritance-mapping would resolve to reference/reference/inheritance-mapping.rst, because it is relative to the current document --- docs/en/reference/architecture.rst | 2 +- docs/en/reference/configuration.rst | 2 +- docs/en/reference/dql-doctrine-query-language.rst | 2 +- docs/en/reference/improving-performance.rst | 2 +- docs/en/reference/inheritance-mapping.rst | 6 +++--- docs/en/reference/installation.rst | 2 +- docs/en/reference/limitations-and-known-issues.rst | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/en/reference/architecture.rst b/docs/en/reference/architecture.rst index 67e1388d9ca..c9d16a69ff9 100644 --- a/docs/en/reference/architecture.rst +++ b/docs/en/reference/architecture.rst @@ -102,7 +102,7 @@ persistent entity state and mapping information for its subclasses, but which is not itself an entity. Mapped superclasses are explained in greater detail in the chapter -on :doc:`inheritance mapping `. +on :doc:`inheritance mapping `. Transient Classes ~~~~~~~~~~~~~~~~~ diff --git a/docs/en/reference/configuration.rst b/docs/en/reference/configuration.rst index 831ae91a16a..a65ede69b28 100644 --- a/docs/en/reference/configuration.rst +++ b/docs/en/reference/configuration.rst @@ -104,7 +104,7 @@ Inside the ``ORMSetup`` methods several assumptions are made: In order to have ``ORMSetup`` configure the cache automatically, the library ``symfony/cache`` has to be installed as a dependency. -If you want to configure Doctrine in more detail, take a look at the :doc:`Advanced Configuration ` section. +If you want to configure Doctrine in more detail, take a look at the :doc:`Advanced Configuration ` section. .. note:: diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index 7e64ac0263c..38c54024226 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -1336,7 +1336,7 @@ There are situations when a query you want to execute returns a very large result-set that needs to be processed. All the previously described hydration modes completely load a result-set into memory which might not be feasible with large result sets. See -the :doc:`Batch Processing ` section on +the :doc:`Batch Processing ` section on details how to iterate large result sets. Functions diff --git a/docs/en/reference/improving-performance.rst b/docs/en/reference/improving-performance.rst index 79610644a7c..0022efdd271 100644 --- a/docs/en/reference/improving-performance.rst +++ b/docs/en/reference/improving-performance.rst @@ -91,7 +91,7 @@ Apply Best Practices A lot of the points mentioned in the Best Practices chapter will also positively affect the performance of Doctrine. -See :doc:`Best Practices ` +See :doc:`Best Practices ` Change Tracking policies ------------------------ diff --git a/docs/en/reference/inheritance-mapping.rst b/docs/en/reference/inheritance-mapping.rst index fc972cc10e1..0e23c8f7d87 100644 --- a/docs/en/reference/inheritance-mapping.rst +++ b/docs/en/reference/inheritance-mapping.rst @@ -35,7 +35,7 @@ have to be used. superclass, since they require the "many" side to hold the foreign key. - It is, however, possible to use the :doc:`ResolveTargetEntityListener ` + It is, however, possible to use the :doc:`ResolveTargetEntityListener ` to replace references to a mapped superclass with an entity class at runtime. As long as there is only one entity subclass inheriting from the mapped superclass and all references to the mapped superclass are resolved to that @@ -60,7 +60,7 @@ have to be used. You may be tempted to use traits to mix mapped fields or relationships into your entity classes to circumvent some of the limitations of mapped superclasses. Before doing that, please read the section on traits - in the :doc:`Limitations and Known Issues ` chapter. + in the :doc:`Limitations and Known Issues ` chapter. Example: @@ -380,7 +380,7 @@ It is not supported to use overrides in entity inheritance scenarios. .. note:: When using traits, make sure not to miss the warnings given in the - :doc:`Limitations and Known Issues` chapter. + :doc:`Limitations and Known Issues` chapter. Association Override diff --git a/docs/en/reference/installation.rst b/docs/en/reference/installation.rst index 639a3bfd911..dab1364f777 100644 --- a/docs/en/reference/installation.rst +++ b/docs/en/reference/installation.rst @@ -1,4 +1,4 @@ Installation ============ -The installation chapter has moved to :doc:`Installation and Configuration `. +The installation chapter has moved to :doc:`Installation and Configuration `. diff --git a/docs/en/reference/limitations-and-known-issues.rst b/docs/en/reference/limitations-and-known-issues.rst index aa0c2920046..8a960f63d76 100644 --- a/docs/en/reference/limitations-and-known-issues.rst +++ b/docs/en/reference/limitations-and-known-issues.rst @@ -145,7 +145,7 @@ more than two years after the initial Doctrine 2 release and the time where core components were designed. In fact, this documentation mentions traits only in the context of -:doc:`overriding field association mappings in subclasses `. +:doc:`overriding field association mappings in subclasses `. Coverage of traits in test cases is practically nonexistent. Thus, you should at least be aware that when using traits in your entity and From 075824f5b5b7fe6b1652baa336bc0bf8f9da096a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Ku=C5=BAnik?= Date: Wed, 5 Jul 2023 21:10:48 +0200 Subject: [PATCH 45/72] Fix code style issues --- lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php index 898d86f32bc..abd94fd01e3 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php @@ -376,7 +376,7 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad continue; } - $mapping = $this->columnToArray($idElement); + $mapping = $this->columnToArray($idElement); $mapping['id'] = true; $metadata->mapField($mapping); From c1018fe299d96279c0c52c183cc5961b9d3b06a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rokas=20Mikalk=C4=97nas?= Date: Wed, 28 Jun 2023 13:26:53 +0300 Subject: [PATCH 46/72] Fixes recomputation of single entity change set when entity contains enum attributes. Due to the fact that originalEntityData contains enum objects and ReflectionEnumProperty::getValue() returns value of enum, comparison of values are always falsy, resulting to update columns value even though it has not changes. --- lib/Doctrine/ORM/UnitOfWork.php | 14 ++++ .../Tests/ORM/Functional/EnumTest.php | 72 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index cc1c5ab22bc..a1b80c565b9 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -1124,6 +1124,20 @@ public function recomputeSingleEntityChangeSet(ClassMetadata $class, $entity) foreach ($actualData as $propName => $actualValue) { $orgValue = $originalData[$propName] ?? null; + if (isset($class->fieldMappings[$propName]['enumType'])) { + if (is_array($orgValue)) { + foreach ($orgValue as $id => $val) { + if ($val instanceof BackedEnum) { + $orgValue[$id] = $val->value; + } + } + } else { + if ($orgValue instanceof BackedEnum) { + $orgValue = $orgValue->value; + } + } + } + if ($orgValue !== $actualValue) { $changeSet[$propName] = [$orgValue, $actualValue]; } diff --git a/tests/Doctrine/Tests/ORM/Functional/EnumTest.php b/tests/Doctrine/Tests/ORM/Functional/EnumTest.php index 3da2ea5f4ff..1d817d8d2ed 100644 --- a/tests/Doctrine/Tests/ORM/Functional/EnumTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/EnumTest.php @@ -260,6 +260,78 @@ public function testEnumArrayInDtoHydration(): void self::assertEqualsCanonicalizing([Unit::Gram, Unit::Meter], $result[0]->supportedUnits); } + public function testEnumSingleEntityChangeSetsSimpleObjectHydrator(): void + { + $this->setUpEntitySchema([Card::class]); + + $card = new Card(); + $card->suit = Suit::Clubs; + + $this->_em->persist($card); + $this->_em->flush(); + $this->_em->clear(); + + $result = $this->_em->find(Card::class, $card->id); + + $this->_em->getUnitOfWork()->recomputeSingleEntityChangeSet( + $this->_em->getClassMetadata(Card::class), + $result + ); + + self::assertFalse($this->_em->getUnitOfWork()->isScheduledForUpdate($result)); + + $result->suit = Suit::Hearts; + + $this->_em->getUnitOfWork()->recomputeSingleEntityChangeSet( + $this->_em->getClassMetadata(Card::class), + $result + ); + + self::assertTrue($this->_em->getUnitOfWork()->isScheduledForUpdate($result)); + } + + public function testEnumSingleEntityChangeSetsObjectHydrator(): void + { + $this->setUpEntitySchema([Card::class]); + + $card = new Card(); + $card->suit = Suit::Clubs; + + $this->_em->persist($card); + $this->_em->flush(); + $this->_em->clear(); + + $result = $this->_em->find(Card::class, $card->id); + + $this->_em->getUnitOfWork()->recomputeSingleEntityChangeSet( + $this->_em->getClassMetadata(Card::class), + $result + ); + + self::assertFalse($this->_em->getUnitOfWork()->isScheduledForUpdate($result)); + } + + public function testEnumArraySingleEntityChangeSets(): void + { + $this->setUpEntitySchema([Scale::class]); + + $scale = new Scale(); + $scale->supportedUnits = [Unit::Gram]; + + $this->_em->persist($scale); + $this->_em->flush(); + $this->_em->clear(); + + $result = $this->_em->find(Scale::class, $scale->id); + + $this->_em->getUnitOfWork()->recomputeSingleEntityChangeSet( + $this->_em->getClassMetadata(Scale::class), + $result + ); + + self::assertFalse($this->_em->getUnitOfWork()->isScheduledForUpdate($result)); + } + public function testEnumChangeSetsSimpleObjectHydrator(): void { $this->setUpEntitySchema([Card::class]); From eee87c376db3874bf941ae0e9f0c226beba11b33 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 6 Jul 2023 14:28:12 +0200 Subject: [PATCH 47/72] Fix cloning entities when using lazy-ghost proxies --- lib/Doctrine/ORM/Proxy/ProxyFactory.php | 60 +++++++++++-------- phpstan-baseline.neon | 2 +- psalm-baseline.xml | 7 ++- .../Tests/ORM/Proxy/ProxyFactoryTest.php | 20 ++----- 4 files changed, 47 insertions(+), 42 deletions(-) diff --git a/lib/Doctrine/ORM/Proxy/ProxyFactory.php b/lib/Doctrine/ORM/Proxy/ProxyFactory.php index 2ba41caba72..e9992f3ca22 100644 --- a/lib/Doctrine/ORM/Proxy/ProxyFactory.php +++ b/lib/Doctrine/ORM/Proxy/ProxyFactory.php @@ -48,13 +48,12 @@ class extends \ implements \ - /** - * @internal - */ - public bool $__isCloning = false; - - public function __construct(?\Closure $initializer = null) + public function __construct(?\Closure $initializer = null, ?\Closure $cloner = null) { + if ($cloner !== null) { + return; + } + self::createLazyGhost($initializer, , $this); } @@ -63,17 +62,6 @@ public function __isInitialized(): bool return isset($this->lazyObjectState) && $this->isLazyObjectInitialized(); } - public function __clone() - { - $this->__isCloning = true; - - try { - $this->__doClone(); - } finally { - $this->__isCloning = false; - } - } - public function __serialize(): array { @@ -98,6 +86,9 @@ public function __serialize(): array */ private $identifierFlattener; + /** @var ProxyDefinition[] */ + private $definitions = []; + /** * Initializes a new instance of the ProxyFactory class that is * connected to the given EntityManager. @@ -131,6 +122,26 @@ public function __construct(EntityManagerInterface $em, $proxyDir, $proxyNs, $au $this->identifierFlattener = new IdentifierFlattener($this->uow, $em->getMetadataFactory()); } + /** + * {@inheritDoc} + */ + public function getProxy($className, array $identifier) + { + $proxy = parent::getProxy($className, $identifier); + + if (! $this->em->getConfiguration()->isLazyGhostObjectEnabled()) { + return $proxy; + } + + $initializer = $this->definitions[$className]->initializer; + + $proxy->__construct(static function (Proxy $object) use ($initializer, $proxy): void { + $initializer($object, $proxy); + }); + + return $proxy; + } + /** * {@inheritDoc} */ @@ -158,7 +169,7 @@ protected function createProxyDefinition($className) $cloner = $this->createCloner($classMetadata, $entityPersister); } - return new ProxyDefinition( + return $this->definitions[$className] = new ProxyDefinition( ClassUtils::generateProxyClassName($className, $this->proxyNs), $classMetadata->getIdentifierFieldNames(), $classMetadata->getReflectionProperties(), @@ -231,15 +242,15 @@ private function createInitializer(ClassMetadata $classMetadata, EntityPersister /** * Creates a closure capable of initializing a proxy * - * @return Closure(Proxy):void + * @return Closure(Proxy, 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); + return function (Proxy $proxy, Proxy $original) use ($entityPersister, $classMetadata): void { + $identifier = $classMetadata->getIdentifierValues($original); + $entity = $entityPersister->loadById($identifier, $original); if ($entity === null) { throw EntityNotFoundException::fromClassNameAndIdentifier( @@ -248,7 +259,7 @@ private function createLazyInitializer(ClassMetadata $classMetadata, EntityPersi ); } - if (! $proxy->__isCloning) { + if ($proxy === $original) { return; } @@ -315,7 +326,6 @@ private function generateUseLazyGhostTrait(ClassMetadata $class): string isLazyObjectInitialized as private; createLazyGhost as private; resetLazyObject as private; - __clone as private __doClone; }'), $code); return $code; @@ -323,7 +333,7 @@ private function generateUseLazyGhostTrait(ClassMetadata $class): string private function generateSkippedProperties(ClassMetadata $class): string { - $skippedProperties = ['__isCloning' => true]; + $skippedProperties = []; $identifiers = array_flip($class->getIdentifierFieldNames()); $filter = ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE; $reflector = $class->getReflectionClass(); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 42b40438d7d..87d9dc9837c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -286,7 +286,7 @@ parameters: path: lib/Doctrine/ORM/Proxy/ProxyFactory.php - - message: "#^Access to an undefined property Doctrine\\\\Persistence\\\\Proxy\\:\\:\\$__isCloning\\.$#" + message: "#^Call to an undefined method Doctrine\\\\Common\\\\Proxy\\\\Proxy\\:\\:__construct\\(\\)\\.$#" count: 1 path: lib/Doctrine/ORM/Proxy/ProxyFactory.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index e24083e4046..37cd0acd5eb 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1392,6 +1392,11 @@ $classMetadata $classMetadata + + __construct(static function (Proxy $object) use ($initializer, $proxy): void { + $initializer($object, $proxy); + })]]> + getReflectionProperties()]]> getMetadataFactory()]]> @@ -1400,7 +1405,6 @@ isEmbeddedClass]]> isMappedSuperclass]]> - __isCloning]]> name]]> @@ -1411,6 +1415,7 @@ setAccessible + __construct __wakeup diff --git a/tests/Doctrine/Tests/ORM/Proxy/ProxyFactoryTest.php b/tests/Doctrine/Tests/ORM/Proxy/ProxyFactoryTest.php index 804cbbe040e..3739aaf4dd5 100644 --- a/tests/Doctrine/Tests/ORM/Proxy/ProxyFactoryTest.php +++ b/tests/Doctrine/Tests/ORM/Proxy/ProxyFactoryTest.php @@ -5,7 +5,6 @@ namespace Doctrine\Tests\ORM\Proxy; use Doctrine\Common\EventManager; -use Doctrine\Common\Proxy\Proxy as CommonProxy; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\ORM\EntityNotFoundException; @@ -227,21 +226,12 @@ public function testProxyClonesParentFields(): void ->expects(self::atLeastOnce()) ->method('loadById'); - if ($proxy instanceof CommonProxy) { - $loadByIdMock->willReturn($companyEmployee); + $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; - }); - } + $persister + ->expects(self::atLeastOnce()) + ->method('getClassMetadata') + ->willReturn($classMetaData); $cloned = clone $proxy; assert($cloned instanceof CompanyEmployee); From 450cae2caae9bb1802751c4cf1a18a31f8b799f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Fri, 7 Jul 2023 13:59:27 +0200 Subject: [PATCH 48/72] Add dummy title to the sidebar before checking docs The way we have our docs, the sidebar is a separate document and as such, needs a title. Let us prepend a dummy title until the guides-cli provides a way to ignore that particular error. --- .github/workflows/documentation.yml | 4 ++++ docs/en/sidebar.rst | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index f7e42ef018c..242bf81fe82 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -43,5 +43,9 @@ jobs: with: dependency-versions: "highest" + - name: "Add dummy title to the sidebar" + run: | + printf '%s\n%s\n\n%s\n' "Dummy title" "===========" "$(cat docs/en/sidebar.rst)" > docs/en/sidebar.rst + - name: "Run guides-cli" run: "vendor/bin/guides -vvv --no-progress docs/en /tmp/test 2>&1 | ( ! grep WARNING )" diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst index 0430bcc7504..c41e758d4b6 100644 --- a/docs/en/sidebar.rst +++ b/docs/en/sidebar.rst @@ -1,3 +1,6 @@ +Dummy title +=========== + .. toc:: .. tocheader:: Tutorials From efc83bce8ec8ae2eaf66c0262f5a37c0a70f3088 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Sat, 8 Jul 2023 15:53:54 +0200 Subject: [PATCH 49/72] Make it possible to have non-NULLable self-referencing associations when using application-provided IDs (#10735) This change improves scheduling of extra updates in the `BasicEntityPersister`. Extra updates can be avoided when * the referred-to entity has already been inserted during the current insert batch/transaction * we have a self-referencing entity with application-provided ID values (the `NONE` generator strategy). As a corollary, with this change applications that provide their own IDs can define self-referencing associations as not NULLable. I am considering this a bugfix since the ORM previously executed additional queries that were not strictly necessary, and that required users to work with NULLable columns where conceptually a non-NULLable column would be valid and more expressive. One caveat, though: In the absence of entity-level commit ordering (#10547), it is not guaranteed that entities with self-references (at the class level) will be inserted in a suitable order. The order depends on the sequence in which the entities were added with `persist()`. Fixes #7877, closes #7882. Co-authored-by: Sylvain Fabre --- .../Entity/BasicEntityPersister.php | 41 +++++- .../Tests/ORM/Functional/GH7877Test.php | 135 ++++++++++++++++++ 2 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 tests/Doctrine/Tests/ORM/Functional/GH7877Test.php diff --git a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php index b84fcb9a688..0fbde8bacb3 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php @@ -266,7 +266,7 @@ public function executeInserts() $stmt = $this->conn->prepare($this->getInsertSQL()); $tableName = $this->class->getTableName(); - foreach ($this->queuedInserts as $entity) { + foreach ($this->queuedInserts as $key => $entity) { $insertData = $this->prepareInsertData($entity); if (isset($insertData[$tableName])) { @@ -291,9 +291,16 @@ public function executeInserts() if ($this->class->requiresFetchAfterChange) { $this->assignDefaultVersionAndUpsertableValues($entity, $id); } - } - $this->queuedInserts = []; + // Unset this queued insert, so that the prepareUpdateData() method knows right away + // (for the next entity already) that the current entity has been written to the database + // and no extra updates need to be scheduled to refer to it. + // + // In \Doctrine\ORM\UnitOfWork::executeInserts(), the UoW already removed entities + // from its own list (\Doctrine\ORM\UnitOfWork::$entityInsertions) right after they + // were given to our addInsert() method. + unset($this->queuedInserts[$key]); + } } /** @@ -671,10 +678,30 @@ protected function prepareUpdateData($entity, bool $isInsert = false) if ($newVal !== null) { $oid = spl_object_id($newVal); - if (isset($this->queuedInserts[$oid]) || $uow->isScheduledForInsert($newVal)) { - // The associated entity $newVal is not yet persisted, so we must - // set $newVal = null, in order to insert a null value and schedule an - // extra update on the UnitOfWork. + // If the associated entity $newVal is not yet persisted and/or does not yet have + // an ID assigned, we must set $newVal = null. This will insert a null value and + // schedule an extra update on the UnitOfWork. + // + // This gives us extra time to a) possibly obtain a database-generated identifier + // value for $newVal, and b) insert $newVal into the database before the foreign + // key reference is being made. + // + // When looking at $this->queuedInserts and $uow->isScheduledForInsert, be aware + // of the implementation details that our own executeInserts() method will remove + // entities from the former as soon as the insert statement has been executed and + // a post-insert ID has been assigned (if necessary), and that the UnitOfWork has + // already removed entities from its own list at the time they were passed to our + // addInsert() method. + // + // Then, there is one extra exception we can make: An entity that references back to itself + // _and_ uses an application-provided ID (the "NONE" generator strategy) also does not + // need the extra update, although it is still in the list of insertions itself. + // This looks like a minor optimization at first, but is the capstone for being able to + // use non-NULLable, self-referencing associations in applications that provide IDs (like UUIDs). + if ( + (isset($this->queuedInserts[$oid]) || $uow->isScheduledForInsert($newVal)) + && ! ($newVal === $entity && $this->class->isIdentifierNatural()) + ) { $uow->scheduleExtraUpdate($entity, [$field => [null, $newVal]]); $newVal = null; diff --git a/tests/Doctrine/Tests/ORM/Functional/GH7877Test.php b/tests/Doctrine/Tests/ORM/Functional/GH7877Test.php new file mode 100644 index 00000000000..6461c686527 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/GH7877Test.php @@ -0,0 +1,135 @@ +createSchemaForModels( + GH7877ApplicationGeneratedIdEntity::class, + GH7877EntityWithNullableAssociation::class + ); + } + + public function testSelfReferenceWithApplicationGeneratedIdMayBeNotNullable(): void + { + $entity = new GH7877ApplicationGeneratedIdEntity(); + $entity->parent = $entity; + + $this->expectNotToPerformAssertions(); + + $this->_em->persist($entity); + $this->_em->flush(); + } + + public function testCrossReferenceWithApplicationGeneratedIdMayBeNotNullable(): void + { + $entity1 = new GH7877ApplicationGeneratedIdEntity(); + $entity1->parent = $entity1; + $entity2 = new GH7877ApplicationGeneratedIdEntity(); + $entity2->parent = $entity1; + + $this->expectNotToPerformAssertions(); + + // As long as we do not have entity-level commit order computation + // (see https://github.com/doctrine/orm/pull/10547), + // this only works when the UoW processes $entity1 before $entity2, + // so that the foreign key constraint E2 -> E1 can be satisfied. + + $this->_em->persist($entity1); + $this->_em->persist($entity2); + $this->_em->flush(); + } + + public function testNullableForeignKeysMakeInsertOrderLessRelevant(): void + { + $entity1 = new GH7877EntityWithNullableAssociation(); + $entity1->parent = $entity1; + $entity2 = new GH7877EntityWithNullableAssociation(); + $entity2->parent = $entity1; + + $this->expectNotToPerformAssertions(); + + // In contrast to the previous test, this case demonstrates that with NULLable + // associations, even without entity-level commit order computation + // (see https://github.com/doctrine/orm/pull/10547), we can get away with an + // insertion order of E2 before E1. That is because the UoW will schedule an extra + // update that saves the day - the foreign key reference will established only after + // all insertions have been performed. + + $this->_em->persist($entity2); + $this->_em->persist($entity1); + $this->_em->flush(); + } +} + +/** + * @ORM\Entity + */ +class GH7877ApplicationGeneratedIdEntity +{ + /** + * @ORM\Id + * @ORM\Column(type="string") + * @ORM\GeneratedValue(strategy="NONE") + * + * @var string + */ + public $id; + + /** + * (!) Note this uses "nullable=false" + * + * @ORM\ManyToOne(targetEntity="GH7877ApplicationGeneratedIdEntity") + * @ORM\JoinColumn(name="parent_id", referencedColumnName="id", nullable=false) + * + * @var self + */ + public $parent; + + public function __construct() + { + $this->id = uniqid(); + } +} + +/** + * @ORM\Entity + */ +class GH7877EntityWithNullableAssociation +{ + /** + * @ORM\Id + * @ORM\Column(type="string") + * @ORM\GeneratedValue(strategy="NONE") + * + * @var string + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity="GH7877EntityWithNullableAssociation") + * @ORM\JoinColumn(name="parent_id", referencedColumnName="id", nullable=true) + * + * @var self + */ + public $parent; + + public function __construct() + { + $this->id = uniqid(); + } +} From f26946b47793f81eb21417c40fed3ab54cb07358 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Sat, 8 Jul 2023 16:22:55 +0200 Subject: [PATCH 50/72] Add `@deprecated` annotations in addition to runtime deprecation notices --- lib/Doctrine/ORM/Internal/CommitOrder/Edge.php | 5 ++++- lib/Doctrine/ORM/Internal/CommitOrder/Vertex.php | 5 ++++- lib/Doctrine/ORM/Internal/CommitOrder/VertexState.php | 5 ++++- lib/Doctrine/ORM/Internal/CommitOrderCalculator.php | 2 ++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/Doctrine/ORM/Internal/CommitOrder/Edge.php b/lib/Doctrine/ORM/Internal/CommitOrder/Edge.php index dc840c93843..98bb7378955 100644 --- a/lib/Doctrine/ORM/Internal/CommitOrder/Edge.php +++ b/lib/Doctrine/ORM/Internal/CommitOrder/Edge.php @@ -6,7 +6,10 @@ use Doctrine\Deprecations\Deprecation; -/** @internal */ +/** + * @internal + * @deprecated + */ final class Edge { /** diff --git a/lib/Doctrine/ORM/Internal/CommitOrder/Vertex.php b/lib/Doctrine/ORM/Internal/CommitOrder/Vertex.php index 969a75b851a..c748bd7eab0 100644 --- a/lib/Doctrine/ORM/Internal/CommitOrder/Vertex.php +++ b/lib/Doctrine/ORM/Internal/CommitOrder/Vertex.php @@ -7,7 +7,10 @@ use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\Mapping\ClassMetadata; -/** @internal */ +/** + * @internal + * @deprecated + */ final class Vertex { /** diff --git a/lib/Doctrine/ORM/Internal/CommitOrder/VertexState.php b/lib/Doctrine/ORM/Internal/CommitOrder/VertexState.php index 9ace165d20d..24d2fb54ee1 100644 --- a/lib/Doctrine/ORM/Internal/CommitOrder/VertexState.php +++ b/lib/Doctrine/ORM/Internal/CommitOrder/VertexState.php @@ -6,7 +6,10 @@ use Doctrine\Deprecations\Deprecation; -/** @internal */ +/** + * @internal + * @deprecated + */ final class VertexState { public const NOT_VISITED = 0; diff --git a/lib/Doctrine/ORM/Internal/CommitOrderCalculator.php b/lib/Doctrine/ORM/Internal/CommitOrderCalculator.php index d45e65c40b5..f0f8112ba23 100644 --- a/lib/Doctrine/ORM/Internal/CommitOrderCalculator.php +++ b/lib/Doctrine/ORM/Internal/CommitOrderCalculator.php @@ -18,6 +18,8 @@ * using a depth-first searching (DFS) to traverse the graph built in memory. * This algorithm have a linear running time based on nodes (V) and dependency * between the nodes (E), resulting in a computational complexity of O(V + E). + * + * @deprecated */ class CommitOrderCalculator { From e94fa8588d7936c8eaf302b4d995c83d22cb5b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Sun, 9 Jul 2023 21:11:50 +0200 Subject: [PATCH 51/72] Match namespace in XML file with namespace in XSD file In 7fa3e6ec7c75e1b4f14ab7e6a1dd80badc4e0fac, a global search and replace was used for http and https. This broke the documentation examples in that as soon as you turn on XSD validation, it will fail because the namespace in the XML file does not match the ones defined in the XSD file, which do not exhibit the https. Note that this is not a security concern, because these URIs are not meant to be actually resolved, but to serve as a unique identifier for the namespace in which we define our elements. --- docs/en/reference/events.rst | 8 +++--- docs/en/reference/second-level-cache.rst | 10 +++++-- docs/en/reference/xml-mapping.rst | 12 ++++---- docs/en/tutorials/composite-primary-keys.rst | 8 +++--- docs/en/tutorials/extra-lazy-associations.rst | 4 +-- docs/en/tutorials/getting-started.rst | 28 +++++++++---------- .../working-with-indexed-associations.rst | 8 +++--- 7 files changed, 42 insertions(+), 36 deletions(-) diff --git a/docs/en/reference/events.rst b/docs/en/reference/events.rst index 97c3a6f339c..c2dfcecc9fc 100644 --- a/docs/en/reference/events.rst +++ b/docs/en/reference/events.rst @@ -281,10 +281,10 @@ specific to a particular entity class's lifecycle. - + diff --git a/docs/en/reference/second-level-cache.rst b/docs/en/reference/second-level-cache.rst index e5bb2c2b610..cf5cb37d4fa 100644 --- a/docs/en/reference/second-level-cache.rst +++ b/docs/en/reference/second-level-cache.rst @@ -322,7 +322,10 @@ level cache region. .. code-block:: xml - + @@ -427,7 +430,10 @@ It caches the primary keys of association and cache each element will be cached .. code-block:: xml - + diff --git a/docs/en/reference/xml-mapping.rst b/docs/en/reference/xml-mapping.rst index 67edc4a4965..c8c1abe51d4 100644 --- a/docs/en/reference/xml-mapping.rst +++ b/docs/en/reference/xml-mapping.rst @@ -16,9 +16,9 @@ setup for the latest code in trunk. .. code-block:: xml - ... @@ -102,9 +102,9 @@ of several common elements: // Doctrine.Tests.ORM.Mapping.User.dcm.xml - @@ -769,9 +769,9 @@ entity relationship. You can define this in XML with the "association-key" attri .. code-block:: xml - diff --git a/docs/en/tutorials/composite-primary-keys.rst b/docs/en/tutorials/composite-primary-keys.rst index 62111cdd1e3..456adeaf5de 100644 --- a/docs/en/tutorials/composite-primary-keys.rst +++ b/docs/en/tutorials/composite-primary-keys.rst @@ -85,9 +85,9 @@ and year of production as primary keys: .. code-block:: xml - @@ -267,9 +267,9 @@ We keep up the example of an Article with arbitrary attributes, the mapping look .. code-block:: xml - diff --git a/docs/en/tutorials/extra-lazy-associations.rst b/docs/en/tutorials/extra-lazy-associations.rst index ca558f4530d..fbff96f428b 100644 --- a/docs/en/tutorials/extra-lazy-associations.rst +++ b/docs/en/tutorials/extra-lazy-associations.rst @@ -85,9 +85,9 @@ switch to extra lazy as shown in these examples: .. code-block:: xml - diff --git a/docs/en/tutorials/getting-started.rst b/docs/en/tutorials/getting-started.rst index 2cd398cb6ad..023d89c211e 100644 --- a/docs/en/tutorials/getting-started.rst +++ b/docs/en/tutorials/getting-started.rst @@ -557,10 +557,10 @@ methods, but you only need to choose one. .. code-block:: xml - + @@ -1138,10 +1138,10 @@ the ``Product`` before: .. code-block:: xml - + @@ -1293,10 +1293,10 @@ Finally, we'll add metadata mappings for the ``User`` entity. .. code-block:: xml - + @@ -1818,9 +1818,9 @@ we have to adjust the metadata slightly. .. code-block:: xml - diff --git a/docs/en/tutorials/working-with-indexed-associations.rst b/docs/en/tutorials/working-with-indexed-associations.rst index b09da398249..c4099eb5589 100644 --- a/docs/en/tutorials/working-with-indexed-associations.rst +++ b/docs/en/tutorials/working-with-indexed-associations.rst @@ -161,9 +161,9 @@ The code and mappings for the Market entity looks like this: .. code-block:: xml - @@ -278,9 +278,9 @@ here are the code and mappings for it: .. code-block:: xml - From a555626150470807f8fbbb0b7daf49bc11317057 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Tue, 11 Jul 2023 00:07:02 +0200 Subject: [PATCH 52/72] Improve and add test to set to-one and to-many associations with reference objects (#10799) --- .../ORM/Functional/BasicFunctionalTest.php | 69 ++++++++++++++----- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php b/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php index f2681258018..812cfc60660 100644 --- a/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php @@ -15,6 +15,7 @@ use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsArticle; use Doctrine\Tests\Models\CMS\CmsComment; +use Doctrine\Tests\Models\CMS\CmsGroup; use Doctrine\Tests\Models\CMS\CmsPhonenumber; use Doctrine\Tests\Models\CMS\CmsUser; use Doctrine\Tests\OrmFunctionalTestCase; @@ -516,42 +517,78 @@ public function testInitializeCollectionWithNewObjectsRetainsNewObjects(): void self::assertEquals(4, $gblanco2->getPhonenumbers()->count()); } - public function testSetSetAssociationWithGetReference(): void + public function testSetToOneAssociationWithGetReference(): void { $user = new CmsUser(); $user->name = 'Guilherme'; $user->username = 'gblanco'; $user->status = 'developer'; $this->_em->persist($user); + $this->_em->flush(); + $this->_em->clear(); - $address = new CmsAddress(); - $address->country = 'Germany'; - $address->city = 'Berlin'; - $address->zip = '12345'; - $this->_em->persist($address); + // Assume we only got the identifier of the user and now want to attach + // the article to the user without actually loading it, using getReference(). + $userRef = $this->_em->getReference(CmsUser::class, $user->getId()); + self::assertInstanceOf(Proxy::class, $userRef); + self::assertFalse($userRef->__isInitialized()); + + $article = new CmsArticle(); + $article->topic = 'topic'; + $article->text = 'text'; + $article->setAuthor($userRef); + $this->_em->persist($article); $this->_em->flush(); - $this->_em->clear(CmsAddress::class); - self::assertFalse($this->_em->contains($address)); - self::assertTrue($this->_em->contains($user)); + self::assertFalse($userRef->__isInitialized()); + + $this->_em->clear(); - // Assume we only got the identifier of the address and now want to attach - // that address to the user without actually loading it, using getReference(). - $addressRef = $this->_em->getReference(CmsAddress::class, $address->getId()); + // Check with a fresh load that the association is indeed there + $query = $this->_em->createQuery("select u, a from Doctrine\Tests\Models\CMS\CmsUser u join u.articles a where u.username='gblanco'"); + $gblanco = $query->getSingleResult(); + + self::assertInstanceOf(CmsUser::class, $gblanco); + self::assertInstanceOf(CmsArticle::class, $gblanco->articles[0]); + self::assertSame($article->id, $gblanco->articles[0]->id); + self::assertSame('text', $gblanco->articles[0]->text); + } + + public function testAddToToManyAssociationWithGetReference(): void + { + $group = new CmsGroup(); + $group->name = 'admins'; + $this->_em->persist($group); + $this->_em->flush(); + $this->_em->clear(); + + // Assume we only got the identifier of the user and now want to attach + // the article to the user without actually loading it, using getReference(). + $groupRef = $this->_em->getReference(CmsGroup::class, $group->id); + self::assertInstanceOf(Proxy::class, $groupRef); + self::assertFalse($groupRef->__isInitialized()); - $user->setAddress($addressRef); // Ugh! Initializes address 'cause of $address->setUser($user)! + $user = new CmsUser(); + $user->name = 'Guilherme'; + $user->username = 'gblanco'; + $user->groups->add($groupRef); + $this->_em->persist($user); $this->_em->flush(); + + self::assertFalse($groupRef->__isInitialized()); + $this->_em->clear(); // Check with a fresh load that the association is indeed there - $query = $this->_em->createQuery("select u, a from Doctrine\Tests\Models\CMS\CmsUser u join u.address a where u.username='gblanco'"); + $query = $this->_em->createQuery("select u, a from Doctrine\Tests\Models\CMS\CmsUser u join u.groups a where u.username='gblanco'"); $gblanco = $query->getSingleResult(); self::assertInstanceOf(CmsUser::class, $gblanco); - self::assertInstanceOf(CmsAddress::class, $gblanco->getAddress()); - self::assertEquals('Berlin', $gblanco->getAddress()->getCity()); + self::assertInstanceOf(CmsGroup::class, $gblanco->groups[0]); + self::assertSame($group->id, $gblanco->groups[0]->id); + self::assertSame('admins', $gblanco->groups[0]->name); } public function testOneToManyCascadeRemove(): void From ca7abd04a2f07000785b31ef018cc1448c654730 Mon Sep 17 00:00:00 2001 From: Yevhen Vilkhovchenko Date: Wed, 22 Mar 2023 18:05:25 +0200 Subject: [PATCH 53/72] Fix persist notInsertable|notUpdatable columns of entity with joined inheritance type 1. Postgres gives error when insert root entity ($rootTableStmt->executeStatement()) in JoinedSubclassPersister::executeInserts(): PDOException: SQLSTATE[08P01]: <>: 7 ERROR: bind message supplies 4 parameters, but prepared statement "" requires 6 so exclude notInsertable columns from JoinedSubclassPersister::getInsertColumnList() like it done in parent::prepareInsertData() which call BasicEntityPersister::prepareUpdateData(isInsert: true) where we have condition: if ($isInsert && isset($fieldMapping['notInsertable'])) 2. Try to get generated (notInsertable|notUpdatable) column value on flush() with JoinedSubclassPersister::executeInserts() also fails: Unexpected empty result for database query. because method it calls $this->assignDefaultVersionAndUpsertableValues() after insert root entity row, while generated columns in child-entity table, so move call just after insert child row 3. Use option['default'] = 'dbDefault' in functional test entities, to emulate generated value on insert, but declare as generated = 'ALWAYS' for tests purpose (correctness of JoinedSubclassPersister::fetchVersionAndNotUpsertableValues() sql-query) 4. Use JoinedInheritanceRoot::rootField to skip JoinedSubclassPersister::update() optimization for empty changeset in updatable:false columns tests --- .../Entity/JoinedSubclassPersister.php | 9 +- .../Functional/Ticket/GH9467/GH9467Test.php | 200 ++++++++++++++++++ .../JoinedInheritanceNonInsertableColumn.php | 25 +++ .../JoinedInheritanceNonUpdatableColumn.php | 25 +++ .../JoinedInheritanceNonWritableColumn.php | 25 +++ .../Ticket/GH9467/JoinedInheritanceRoot.php | 52 +++++ .../JoinedInheritanceWritableColumn.php | 25 +++ 7 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/GH9467Test.php create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonInsertableColumn.php create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonUpdatableColumn.php create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonWritableColumn.php create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceRoot.php create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceWritableColumn.php diff --git a/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php b/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php index c872f0a0f69..407348661b4 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php @@ -165,10 +165,6 @@ public function executeInserts() $id = $this->em->getUnitOfWork()->getEntityIdentifier($entity); } - if ($this->class->requiresFetchAfterChange) { - $this->assignDefaultVersionAndUpsertableValues($entity, $id); - } - // Execute inserts on subtables. // The order doesn't matter because all child tables link to the root table via FK. foreach ($subTableStmts as $tableName => $stmt) { @@ -189,6 +185,10 @@ public function executeInserts() $stmt->executeStatement(); } + + if ($this->class->requiresFetchAfterChange) { + $this->assignDefaultVersionAndUpsertableValues($entity, $id); + } } $this->queuedInserts = []; @@ -510,6 +510,7 @@ protected function getInsertColumnList() || isset($this->class->associationMappings[$name]['inherited']) || ($this->class->isVersioned && $this->class->versionField === $name) || isset($this->class->embeddedClasses[$name]) + || isset($this->class->fieldMappings[$name]['notInsertable']) ) { continue; } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/GH9467Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/GH9467Test.php new file mode 100644 index 00000000000..35cd4644d12 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/GH9467Test.php @@ -0,0 +1,200 @@ +createSchemaForModels( + JoinedInheritanceRoot::class, + JoinedInheritanceWritableColumn::class, + JoinedInheritanceNonWritableColumn::class, + JoinedInheritanceNonInsertableColumn::class, + JoinedInheritanceNonUpdatableColumn::class + ); + } + + public function testChildWritableColumnInsert(): int + { + $entity = new JoinedInheritanceWritableColumn(); + $entity->writableContent = 'foo'; + + $this->_em->persist($entity); + $this->_em->flush(); + + // check INSERT query doesn't change insertable entity property + self::assertEquals('foo', $entity->writableContent); + + // check other process get same state + $this->_em->clear(); + $entity = $this->_em->find(JoinedInheritanceWritableColumn::class, $entity->id); + self::assertInstanceOf(JoinedInheritanceWritableColumn::class, $entity); + self::assertEquals('foo', $entity->writableContent); + + return $entity->id; + } + + /** @depends testChildWritableColumnInsert */ + public function testChildWritableColumnUpdate(int $entityId): void + { + $entity = $this->_em->find(JoinedInheritanceWritableColumn::class, $entityId); + self::assertInstanceOf(JoinedInheritanceWritableColumn::class, $entity); + + // update exist entity + $entity->writableContent = 'bar'; + + $this->_em->persist($entity); + $this->_em->flush(); + + // check UPDATE query doesn't change updatable entity property + self::assertEquals('bar', $entity->writableContent); + + // check other process get same state + $this->_em->clear(); + $entity = $this->_em->find(JoinedInheritanceWritableColumn::class, $entity->id); + self::assertInstanceOf(JoinedInheritanceWritableColumn::class, $entity); + self::assertEquals('bar', $entity->writableContent); + } + + public function testChildNonWritableColumnInsert(): int + { + $entity = new JoinedInheritanceNonWritableColumn(); + $entity->nonWritableContent = 'foo'; + + $this->_em->persist($entity); + $this->_em->flush(); + + // check INSERT query cause set database value into non-insertable entity property + self::assertEquals('dbDefault', $entity->nonWritableContent); + + // check other process get same state + $this->_em->clear(); + $entity = $this->_em->find(JoinedInheritanceNonWritableColumn::class, $entity->id); + self::assertInstanceOf(JoinedInheritanceNonWritableColumn::class, $entity); + self::assertEquals('dbDefault', $entity->nonWritableContent); + + return $entity->id; + } + + /** @depends testChildNonWritableColumnInsert */ + public function testChildNonWritableColumnUpdate(int $entityId): void + { + $entity = $this->_em->find(JoinedInheritanceNonWritableColumn::class, $entityId); + self::assertInstanceOf(JoinedInheritanceNonWritableColumn::class, $entity); + + // update exist entity + $entity->nonWritableContent = 'bar'; + // change some property to ensure UPDATE query will be done + self::assertNotEquals('bar', $entity->rootField); + $entity->rootField = 'bar'; + + $this->_em->persist($entity); + $this->_em->flush(); + + // check UPDATE query cause set database value into non-updatable entity property + self::assertEquals('dbDefault', $entity->nonWritableContent); + + // check other process get same state + $this->_em->clear(); + $entity = $this->_em->find(JoinedInheritanceNonWritableColumn::class, $entity->id); + self::assertInstanceOf(JoinedInheritanceNonWritableColumn::class, $entity); + self::assertEquals('bar', $entity->rootField); // check that UPDATE query done + self::assertEquals('dbDefault', $entity->nonWritableContent); + } + + public function testChildNonInsertableColumnInsert(): int + { + $entity = new JoinedInheritanceNonInsertableColumn(); + $entity->nonInsertableContent = 'foo'; + + $this->_em->persist($entity); + $this->_em->flush(); + + // check INSERT query cause set database value into non-insertable entity property + self::assertEquals('dbDefault', $entity->nonInsertableContent); + + // check other process get same state + $this->_em->clear(); + $entity = $this->_em->find(JoinedInheritanceNonInsertableColumn::class, $entity->id); + self::assertInstanceOf(JoinedInheritanceNonInsertableColumn::class, $entity); + self::assertEquals('dbDefault', $entity->nonInsertableContent); + + return $entity->id; + } + + /** @depends testChildNonInsertableColumnInsert */ + public function testChildNonInsertableColumnUpdate(int $entityId): void + { + $entity = $this->_em->find(JoinedInheritanceNonInsertableColumn::class, $entityId); + self::assertInstanceOf(JoinedInheritanceNonInsertableColumn::class, $entity); + + // update exist entity + $entity->nonInsertableContent = 'bar'; + + $this->_em->persist($entity); + $this->_em->flush(); + + // check UPDATE query doesn't change updatable entity property + self::assertEquals('bar', $entity->nonInsertableContent); + + // check other process get same state + $this->_em->clear(); + $entity = $this->_em->find(JoinedInheritanceNonInsertableColumn::class, $entity->id); + self::assertInstanceOf(JoinedInheritanceNonInsertableColumn::class, $entity); + self::assertEquals('bar', $entity->nonInsertableContent); + } + + public function testChildNonUpdatableColumnInsert(): int + { + $entity = new JoinedInheritanceNonUpdatableColumn(); + $entity->nonUpdatableContent = 'foo'; + + $this->_em->persist($entity); + $this->_em->flush(); + + // check INSERT query doesn't change insertable entity property + self::assertEquals('foo', $entity->nonUpdatableContent); + + // check other process get same state + $this->_em->clear(); + $entity = $this->_em->find(JoinedInheritanceNonUpdatableColumn::class, $entity->id); + self::assertInstanceOf(JoinedInheritanceNonUpdatableColumn::class, $entity); + self::assertEquals('foo', $entity->nonUpdatableContent); + + return $entity->id; + } + + /** @depends testChildNonUpdatableColumnInsert */ + public function testChildNonUpdatableColumnUpdate(int $entityId): void + { + $entity = $this->_em->find(JoinedInheritanceNonUpdatableColumn::class, $entityId); + self::assertInstanceOf(JoinedInheritanceNonUpdatableColumn::class, $entity); + self::assertEquals('foo', $entity->nonUpdatableContent); + + // update exist entity + $entity->nonUpdatableContent = 'bar'; + // change some property to ensure UPDATE query will be done + self::assertNotEquals('bar', $entity->rootField); + $entity->rootField = 'bar'; + + $this->_em->persist($entity); + $this->_em->flush(); + + // check UPDATE query cause set database value into non-updatable entity property + self::assertEquals('foo', $entity->nonUpdatableContent); + + // check other process get same state + $this->_em->clear(); + $entity = $this->_em->find(JoinedInheritanceNonUpdatableColumn::class, $entity->id); + self::assertInstanceOf(JoinedInheritanceNonUpdatableColumn::class, $entity); + self::assertEquals('bar', $entity->rootField); // check that UPDATE query done + self::assertEquals('foo', $entity->nonUpdatableContent); + } +} diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonInsertableColumn.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonInsertableColumn.php new file mode 100644 index 00000000000..1bf575e4289 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonInsertableColumn.php @@ -0,0 +1,25 @@ + 'dbDefault'], generated: 'ALWAYS')] + public $nonInsertableContent; +} diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonUpdatableColumn.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonUpdatableColumn.php new file mode 100644 index 00000000000..c8b1c671f6e --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonUpdatableColumn.php @@ -0,0 +1,25 @@ + 'dbDefault'], generated: 'ALWAYS')] + public $nonUpdatableContent; +} diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonWritableColumn.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonWritableColumn.php new file mode 100644 index 00000000000..2f78f6b5e5f --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceNonWritableColumn.php @@ -0,0 +1,25 @@ + 'dbDefault'], generated: 'ALWAYS')] + public $nonWritableContent; +} diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceRoot.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceRoot.php new file mode 100644 index 00000000000..68b1855b9f7 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceRoot.php @@ -0,0 +1,52 @@ + JoinedInheritanceWritableColumn::class, 'nonWritable' => JoinedInheritanceNonWritableColumn::class, 'nonInsertable' => JoinedInheritanceNonInsertableColumn::class, 'nonUpdatable' => JoinedInheritanceNonUpdatableColumn::class])] +class JoinedInheritanceRoot +{ + /** + * @var int + * @Id + * @GeneratedValue + * @Column(type="integer") + */ + #[Id] + #[GeneratedValue] + #[Column(type: 'integer')] + public $id; + + /** + * @var string + * @Column(type="string") + */ + #[Column(type: 'string')] + public $rootField = ''; +} diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceWritableColumn.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceWritableColumn.php new file mode 100644 index 00000000000..874df49e6b6 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceWritableColumn.php @@ -0,0 +1,25 @@ + 'dbDefault'], generated: 'ALWAYS')] + public $writableContent; +} From f3e36debfe2b51c7d4ec490760e0acd641cefc35 Mon Sep 17 00:00:00 2001 From: Yevhen Vilkhovchenko Date: Thu, 23 Mar 2023 18:10:38 +0200 Subject: [PATCH 54/72] Fix persist notInsertable|notUpdatable fields of root entity of joined inheritance type 1. Inherit ClassMetadataInfo::requiresFetchAfterChange flag from root entity when process parent columns mapping (see ClassMetadataInfo::addInheritedFieldMapping(), it uses same condition as ClassMetadataInfo::mapField()) so JoinedSubclassPersister::assignDefaultVersionAndUpsertableValues() to be called in JoinedSubclassPersister::executeInserts(). 2. Override JoinedSubclassPersister::fetchVersionAndNotUpsertableValues() to fetch all parent tables (see $this->getJoinSql() call) generated columns. So make protected BasicEntityPersister::identifierFlattener stateless service (use it flattenIdentifier() method) and BasicEntityPersister::extractIdentifierTypes() (to avoid copy-paste). 3. JoinedSubclassPersister::fetchVersionAndNotUpsertableValues() doesnt check empty $columnNames because it would be an error if ClassMetadataInfo::requiresFetchAfterChange is true while no generated columns in inheritance hierarchy. 4. Initialize JoinedInheritanceRoot not-nullable string properties with insertable=false attribute to avoid attempt to insert default null data which cause error: PDOException: SQLSTATE[23502]: Not null violation: 7 ERROR: null value in column "rootwritablecontent" of relation "joined_inheritance_root" violates not-null constraint DETAIL: Failing row contains (5, null, dbDefault, dbDefault, , nonUpdatable). while $rootTableStmt->executeStatement() because JoinedSubclassPersister::getInsertColumnList() have no $insertData (prepared later) to decide is generated column provided by client code or not (so need to skip column) --- .../ORM/Mapping/ClassMetadataInfo.php | 4 ++ .../Entity/BasicEntityPersister.php | 4 +- .../Entity/JoinedSubclassPersister.php | 57 +++++++++++++++++ .../Functional/Ticket/GH9467/GH9467Test.php | 61 +++++++++++++++++++ .../Ticket/GH9467/JoinedInheritanceChild.php | 18 ++++++ .../Ticket/GH9467/JoinedInheritanceRoot.php | 31 +++++++++- 6 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceChild.php diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php index 70e22aa048d..b3167948cb8 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php @@ -2764,6 +2764,10 @@ public function addInheritedFieldMapping(array $fieldMapping) $this->fieldMappings[$fieldMapping['fieldName']] = $fieldMapping; $this->columnNames[$fieldMapping['fieldName']] = $fieldMapping['columnName']; $this->fieldNames[$fieldMapping['columnName']] = $fieldMapping['fieldName']; + + if (isset($fieldMapping['generated'])) { + $this->requiresFetchAfterChange = true; + } } /** diff --git a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php index 0fbde8bacb3..a29a6682b44 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php @@ -183,7 +183,7 @@ class BasicEntityPersister implements EntityPersister * * @var IdentifierFlattener */ - private $identifierFlattener; + protected $identifierFlattener; /** @var CachedPersisterContext */ protected $currentPersisterContext; @@ -379,7 +379,7 @@ protected function fetchVersionAndNotUpsertableValues($versionedClass, array $id * @return int[]|null[]|string[] * @psalm-return list */ - private function extractIdentifierTypes(array $id, ClassMetadata $versionedClass): array + protected function extractIdentifierTypes(array $id, ClassMetadata $versionedClass): array { $types = []; diff --git a/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php b/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php index 407348661b4..f286ac1d8e4 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php @@ -11,8 +11,11 @@ use Doctrine\ORM\Internal\SQLResultCasing; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Utility\PersisterHelper; +use LengthException; use function array_combine; +use function array_keys; +use function array_values; use function implode; /** @@ -553,6 +556,60 @@ protected function assignDefaultVersionAndUpsertableValues($entity, array $id) } } + /** + * {@inheritDoc} + */ + protected function fetchVersionAndNotUpsertableValues($versionedClass, array $id) + { + $columnNames = []; + foreach ($this->class->fieldMappings as $key => $column) { + $class = null; + if ($this->class->isVersioned && $key === $versionedClass->versionField) { + $class = $versionedClass; + } elseif (isset($column['generated'])) { + $class = isset($column['inherited']) + ? $this->em->getClassMetadata($column['inherited']) + : $this->class; + } else { + continue; + } + + $columnNames[$key] = $this->getSelectColumnSQL($key, $class); + } + + $tableName = $this->quoteStrategy->getTableName($versionedClass, $this->platform); + $baseTableAlias = $this->getSQLTableAlias($this->class->name); + $joinSql = $this->getJoinSql($baseTableAlias); + $identifier = $this->quoteStrategy->getIdentifierColumnNames($versionedClass, $this->platform); + foreach ($identifier as $i => $idValue) { + $identifier[$i] = $baseTableAlias . '.' . $idValue; + } + + $sql = 'SELECT ' . implode(', ', $columnNames) + . ' FROM ' . $tableName . ' ' . $baseTableAlias + . $joinSql + . ' WHERE ' . implode(' = ? AND ', $identifier) . ' = ?'; + + $flatId = $this->identifierFlattener->flattenIdentifier($versionedClass, $id); + $values = $this->conn->fetchNumeric( + $sql, + array_values($flatId), + $this->extractIdentifierTypes($id, $versionedClass) + ); + + if ($values === false) { + throw new LengthException('Unexpected empty result for database query.'); + } + + $values = array_combine(array_keys($columnNames), $values); + + if (! $values) { + throw new LengthException('Unexpected number of database columns.'); + } + + return $values; + } + private function getJoinSql(string $baseTableAlias): string { $joinSql = ''; diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/GH9467Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/GH9467Test.php index 35cd4644d12..01a6acae51e 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/GH9467Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/GH9467Test.php @@ -14,6 +14,7 @@ protected function setUp(): void $this->createSchemaForModels( JoinedInheritanceRoot::class, + JoinedInheritanceChild::class, JoinedInheritanceWritableColumn::class, JoinedInheritanceNonWritableColumn::class, JoinedInheritanceNonInsertableColumn::class, @@ -21,6 +22,66 @@ protected function setUp(): void ); } + public function testRootColumnsInsert(): int + { + $entity = new JoinedInheritanceChild(); + $entity->rootWritableContent = 'foo'; + $entity->rootNonWritableContent = 'foo'; + $entity->rootNonInsertableContent = 'foo'; + $entity->rootNonUpdatableContent = 'foo'; + + $this->_em->persist($entity); + $this->_em->flush(); + + // check INSERT query cause set database values into non-insertable entity properties + self::assertEquals('foo', $entity->rootWritableContent); + self::assertEquals('dbDefault', $entity->rootNonWritableContent); + self::assertEquals('dbDefault', $entity->rootNonInsertableContent); + self::assertEquals('foo', $entity->rootNonUpdatableContent); + + // check other process get same state + $this->_em->clear(); + $entity = $this->_em->find(JoinedInheritanceChild::class, $entity->id); + self::assertInstanceOf(JoinedInheritanceChild::class, $entity); + self::assertEquals('foo', $entity->rootWritableContent); + self::assertEquals('dbDefault', $entity->rootNonWritableContent); + self::assertEquals('dbDefault', $entity->rootNonInsertableContent); + self::assertEquals('foo', $entity->rootNonUpdatableContent); + + return $entity->id; + } + + /** @depends testRootColumnsInsert */ + public function testRootColumnsUpdate(int $entityId): void + { + $entity = $this->_em->find(JoinedInheritanceChild::class, $entityId); + self::assertInstanceOf(JoinedInheritanceChild::class, $entity); + + // update exist entity + $entity->rootWritableContent = 'bar'; + $entity->rootNonInsertableContent = 'bar'; + $entity->rootNonWritableContent = 'bar'; + $entity->rootNonUpdatableContent = 'bar'; + + $this->_em->persist($entity); + $this->_em->flush(); + + // check UPDATE query cause set database values into non-insertable entity properties + self::assertEquals('bar', $entity->rootWritableContent); + self::assertEquals('dbDefault', $entity->rootNonWritableContent); + self::assertEquals('bar', $entity->rootNonInsertableContent); + self::assertEquals('foo', $entity->rootNonUpdatableContent); + + // check other process get same state + $this->_em->clear(); + $entity = $this->_em->find(JoinedInheritanceChild::class, $entity->id); + self::assertInstanceOf(JoinedInheritanceChild::class, $entity); + self::assertEquals('bar', $entity->rootWritableContent); + self::assertEquals('dbDefault', $entity->rootNonWritableContent); + self::assertEquals('bar', $entity->rootNonInsertableContent); + self::assertEquals('foo', $entity->rootNonUpdatableContent); + } + public function testChildWritableColumnInsert(): int { $entity = new JoinedInheritanceWritableColumn(); diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceChild.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceChild.php new file mode 100644 index 00000000000..0b30c6972a7 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9467/JoinedInheritanceChild.php @@ -0,0 +1,18 @@ + JoinedInheritanceWritableColumn::class, 'nonWritable' => JoinedInheritanceNonWritableColumn::class, 'nonInsertable' => JoinedInheritanceNonInsertableColumn::class, 'nonUpdatable' => JoinedInheritanceNonUpdatableColumn::class])] +#[DiscriminatorMap(['child' => JoinedInheritanceChild::class, 'writable' => JoinedInheritanceWritableColumn::class, 'nonWritable' => JoinedInheritanceNonWritableColumn::class, 'nonInsertable' => JoinedInheritanceNonInsertableColumn::class, 'nonUpdatable' => JoinedInheritanceNonUpdatableColumn::class])] class JoinedInheritanceRoot { /** @@ -49,4 +50,32 @@ class JoinedInheritanceRoot */ #[Column(type: 'string')] public $rootField = ''; + + /** + * @var string + * @Column(type="string", insertable=true, updatable=true, options={"default": "dbDefault"}, generated="ALWAYS") + */ + #[Column(type: 'string', insertable: true, updatable: true, options: ['default' => 'dbDefault'], generated: 'ALWAYS')] + public $rootWritableContent = ''; + + /** + * @var string + * @Column(type="string", insertable=false, updatable=false, options={"default": "dbDefault"}, generated="ALWAYS") + */ + #[Column(type: 'string', insertable: false, updatable: false, options: ['default' => 'dbDefault'], generated: 'ALWAYS')] + public $rootNonWritableContent; + + /** + * @var string + * @Column(type="string", insertable=false, updatable=true, options={"default": "dbDefault"}, generated="ALWAYS") + */ + #[Column(type: 'string', insertable: false, updatable: true, options: ['default' => 'dbDefault'], generated: 'ALWAYS')] + public $rootNonInsertableContent; + + /** + * @var string + * @Column(type="string", insertable=true, updatable=false, options={"default": "dbDefault"}, generated="ALWAYS") + */ + #[Column(type: 'string', insertable: true, updatable: false, options: ['default' => 'dbDefault'], generated: 'ALWAYS')] + public $rootNonUpdatableContent = ''; } From fa5c37e9727a1bcb2f95264d1ce7657847192806 Mon Sep 17 00:00:00 2001 From: Alexandr Vronskiy Date: Fri, 31 Mar 2023 16:57:15 +0300 Subject: [PATCH 55/72] Add final to protected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Grégoire Paris --- lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php index a29a6682b44..7f04c9d5e5d 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php @@ -183,7 +183,7 @@ class BasicEntityPersister implements EntityPersister * * @var IdentifierFlattener */ - protected $identifierFlattener; + final protected $identifierFlattener; /** @var CachedPersisterContext */ protected $currentPersisterContext; @@ -379,7 +379,7 @@ protected function fetchVersionAndNotUpsertableValues($versionedClass, array $id * @return int[]|null[]|string[] * @psalm-return list */ - protected function extractIdentifierTypes(array $id, ClassMetadata $versionedClass): array + final protected function extractIdentifierTypes(array $id, ClassMetadata $versionedClass): array { $types = []; From 3b3056f910f755261a8596cf2623735bfde4149d Mon Sep 17 00:00:00 2001 From: Alexandr Vronskiy Date: Fri, 31 Mar 2023 18:00:22 +0300 Subject: [PATCH 56/72] mistake on final property --- lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php index 7f04c9d5e5d..32ac1d4f531 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php @@ -183,7 +183,7 @@ class BasicEntityPersister implements EntityPersister * * @var IdentifierFlattener */ - final protected $identifierFlattener; + protected $identifierFlattener; /** @var CachedPersisterContext */ protected $currentPersisterContext; From 8c513a65232e3a3d62ff5bea12089b6177c5791b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 12 Jul 2023 00:01:02 +0300 Subject: [PATCH 57/72] Cleanup psalm-type AutogenerateMode (#10833) --- lib/Doctrine/ORM/Configuration.php | 9 ++------- lib/Doctrine/ORM/Proxy/ProxyFactory.php | 12 ++++-------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/lib/Doctrine/ORM/Configuration.php b/lib/Doctrine/ORM/Configuration.php index cef4a9fff5e..fcde0c190fa 100644 --- a/lib/Doctrine/ORM/Configuration.php +++ b/lib/Doctrine/ORM/Configuration.php @@ -62,8 +62,6 @@ * It combines all configuration options from DBAL & ORM. * * Internal note: When adding a new configuration option just write a getter/setter pair. - * - * @psalm-import-type AutogenerateMode from ProxyFactory */ class Configuration extends \Doctrine\DBAL\Configuration { @@ -95,8 +93,7 @@ public function getProxyDir() /** * Gets the strategy for automatically generating proxy classes. * - * @return int Possible values are constants of Doctrine\ORM\Proxy\ProxyFactory. - * @psalm-return AutogenerateMode + * @return ProxyFactory::AUTOGENERATE_* */ public function getAutoGenerateProxyClasses() { @@ -106,9 +103,7 @@ public function getAutoGenerateProxyClasses() /** * Sets the strategy for automatically generating proxy classes. * - * @param bool|int $autoGenerate Possible values are constants of Doctrine\ORM\Proxy\ProxyFactory. - * @psalm-param bool|AutogenerateMode $autoGenerate - * True is converted to AUTOGENERATE_ALWAYS, false to AUTOGENERATE_NEVER. + * @param bool|ProxyFactory::AUTOGENERATE_* $autoGenerate True is converted to AUTOGENERATE_ALWAYS, false to AUTOGENERATE_NEVER. * * @return void */ diff --git a/lib/Doctrine/ORM/Proxy/ProxyFactory.php b/lib/Doctrine/ORM/Proxy/ProxyFactory.php index e9992f3ca22..5f406a05d61 100644 --- a/lib/Doctrine/ORM/Proxy/ProxyFactory.php +++ b/lib/Doctrine/ORM/Proxy/ProxyFactory.php @@ -31,8 +31,6 @@ /** * This factory is used to create proxy objects for entities at runtime. - * - * @psalm-type AutogenerateMode = ProxyFactory::AUTOGENERATE_NEVER|ProxyFactory::AUTOGENERATE_ALWAYS|ProxyFactory::AUTOGENERATE_FILE_NOT_EXISTS|ProxyFactory::AUTOGENERATE_EVAL|ProxyFactory::AUTOGENERATE_FILE_NOT_EXISTS_OR_CHANGED */ class ProxyFactory extends AbstractProxyFactory { @@ -93,12 +91,10 @@ public function __serialize(): array * Initializes a new instance of the ProxyFactory class that is * connected to the given EntityManager. * - * @param EntityManagerInterface $em The EntityManager the new factory works for. - * @param string $proxyDir The directory to use for the proxy classes. It must exist. - * @param string $proxyNs The namespace to use for the proxy classes. - * @param bool|int $autoGenerate The strategy for automatically generating proxy classes. Possible - * values are constants of {@see ProxyFactory::AUTOGENERATE_*}. - * @psalm-param bool|AutogenerateMode $autoGenerate + * @param EntityManagerInterface $em The EntityManager the new factory works for. + * @param string $proxyDir The directory to use for the proxy classes. It must exist. + * @param string $proxyNs The namespace to use for the proxy classes. + * @param bool|self::AUTOGENERATE_* $autoGenerate The strategy for automatically generating proxy classes. */ public function __construct(EntityManagerInterface $em, $proxyDir, $proxyNs, $autoGenerate = self::AUTOGENERATE_NEVER) { From 56e5856ad7c78e87caede1567068cc5ed5985455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Wed, 12 Jul 2023 11:05:36 +0200 Subject: [PATCH 58/72] Remove dummy title This was never meant to be under version control. Did not spot it in the diff. Closes #10836 --- docs/en/sidebar.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst index c41e758d4b6..0430bcc7504 100644 --- a/docs/en/sidebar.rst +++ b/docs/en/sidebar.rst @@ -1,6 +1,3 @@ -Dummy title -=========== - .. toc:: .. tocheader:: Tutorials From dca7ddf9694849418c33fbd3330c1fafa15e74b9 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sat, 15 Jul 2023 10:55:35 +0200 Subject: [PATCH 59/72] Decouple public API from Doctrine\Persistence\Proxy (#10832) --- docs/en/reference/unitofwork.rst | 4 +- lib/Doctrine/ORM/Cache/DefaultQueryCache.php | 3 +- lib/Doctrine/ORM/EntityManager.php | 8 +++ .../ORM/Internal/Hydration/ObjectHydrator.php | 3 +- lib/Doctrine/ORM/Proxy/InternalProxy.php | 19 ++++++ lib/Doctrine/ORM/Proxy/Proxy.php | 6 +- lib/Doctrine/ORM/Proxy/ProxyFactory.php | 9 ++- .../ORM/Tools/DebugUnitOfWorkListener.php | 3 +- lib/Doctrine/ORM/UnitOfWork.php | 64 +++++++++---------- phpstan-persistence2.neon | 5 -- psalm-baseline.xml | 7 +- .../ProxyInitializationTimeBench.php | 2 +- .../ORM/Functional/BasicFunctionalTest.php | 17 ++--- .../Functional/ClassTableInheritanceTest.php | 7 +- .../ORM/Functional/DefaultValuesTest.php | 4 +- .../ORM/Functional/DetachedEntityTest.php | 10 +-- .../ORM/Functional/MappedSuperclassTest.php | 3 +- .../Tests/ORM/Functional/MergeProxiesTest.php | 25 ++++---- .../OneToManyBidirectionalAssociationTest.php | 12 ++-- .../OneToOneBidirectionalAssociationTest.php | 3 +- .../Functional/OneToOneEagerLoadingTest.php | 15 ++--- ...OneToOneSelfReferentialAssociationTest.php | 3 +- .../Functional/ProxiesLikeEntitiesTest.php | 4 +- .../Tests/ORM/Functional/QueryTest.php | 6 +- .../ORM/Functional/ReferenceProxyTest.php | 28 ++++---- .../SecondLevelCacheQueryCacheTest.php | 10 +-- .../SecondLevelCacheRepositoryTest.php | 18 +++--- .../Functional/SingleTableInheritanceTest.php | 8 +-- .../ORM/Functional/Ticket/DDC1163Test.php | 3 +- .../ORM/Functional/Ticket/DDC1193Test.php | 2 +- .../ORM/Functional/Ticket/DDC1228Test.php | 4 +- .../ORM/Functional/Ticket/DDC1452Test.php | 7 +- .../ORM/Functional/Ticket/DDC1690Test.php | 6 +- .../ORM/Functional/Ticket/DDC1734Test.php | 10 ++- .../ORM/Functional/Ticket/DDC2230Test.php | 11 ++-- .../ORM/Functional/Ticket/DDC2231Test.php | 6 +- .../ORM/Functional/Ticket/DDC2306Test.php | 6 +- .../ORM/Functional/Ticket/DDC237Test.php | 8 +-- .../ORM/Functional/Ticket/DDC2494Test.php | 8 +-- .../ORM/Functional/Ticket/DDC2519Test.php | 14 ++-- .../ORM/Functional/Ticket/DDC371Test.php | 3 +- .../ORM/Functional/Ticket/DDC522Test.php | 6 +- .../ORM/Functional/Ticket/DDC531Test.php | 3 +- .../ORM/Functional/Ticket/DDC633Test.php | 6 +- .../ORM/Functional/Ticket/DDC6460Test.php | 6 +- .../ORM/Functional/Ticket/DDC736Test.php | 5 +- .../ORM/Functional/Ticket/DDC881Test.php | 5 +- .../Doctrine/Tests/OrmFunctionalTestCase.php | 6 ++ 48 files changed, 198 insertions(+), 233 deletions(-) create mode 100644 lib/Doctrine/ORM/Proxy/InternalProxy.php diff --git a/docs/en/reference/unitofwork.rst b/docs/en/reference/unitofwork.rst index fc8459cd2ff..4af7f16decf 100644 --- a/docs/en/reference/unitofwork.rst +++ b/docs/en/reference/unitofwork.rst @@ -37,8 +37,8 @@ will still end up with the same reference: public function testIdentityMapReference(): void { $objectA = $this->entityManager->getReference('EntityName', 1); - // check for proxyinterface - $this->assertInstanceOf('Doctrine\Persistence\Proxy', $objectA); + // check entity is not initialized + $this->assertTrue($this->entityManager->isUninitializedObject($objectA)); $objectB = $this->entityManager->find('EntityName', 1); diff --git a/lib/Doctrine/ORM/Cache/DefaultQueryCache.php b/lib/Doctrine/ORM/Cache/DefaultQueryCache.php index ba806eac62a..1c11ebbb8dd 100644 --- a/lib/Doctrine/ORM/Cache/DefaultQueryCache.php +++ b/lib/Doctrine/ORM/Cache/DefaultQueryCache.php @@ -16,7 +16,6 @@ use Doctrine\ORM\Query; use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\ORM\UnitOfWork; -use Doctrine\Persistence\Proxy; use function array_map; use function array_shift; @@ -345,7 +344,7 @@ private function storeAssociationCache(QueryCacheKey $key, array $assoc, $assocV $assocIdentifier = $this->uow->getEntityIdentifier($assocValue); $entityKey = new EntityCacheKey($assocMetadata->rootEntityName, $assocIdentifier); - if (! $assocValue instanceof Proxy && ($key->cacheMode & Cache::MODE_REFRESH) || ! $assocRegion->contains($entityKey)) { + if (! $this->uow->isUninitializedObject($assocValue) && ($key->cacheMode & Cache::MODE_REFRESH) || ! $assocRegion->contains($entityKey)) { // Entity put fail if (! $assocPersister->storeEntityCache($assocValue, $entityKey)) { return null; diff --git a/lib/Doctrine/ORM/EntityManager.php b/lib/Doctrine/ORM/EntityManager.php index 7722b15922d..94a609f0692 100644 --- a/lib/Doctrine/ORM/EntityManager.php +++ b/lib/Doctrine/ORM/EntityManager.php @@ -953,6 +953,14 @@ public function initializeObject($obj) $this->unitOfWork->initializeObject($obj); } + /** + * {@inheritDoc} + */ + public function isUninitializedObject($obj): bool + { + return $this->unitOfWork->isUninitializedObject($obj); + } + /** * Factory method to create EntityManager instances. * diff --git a/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php b/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php index 9918936a3ff..a5d97d30966 100644 --- a/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php +++ b/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php @@ -10,7 +10,6 @@ use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Query; use Doctrine\ORM\UnitOfWork; -use Doctrine\Persistence\Proxy; use function array_fill_keys; use function array_keys; @@ -439,7 +438,7 @@ protected function hydrateRowData(array $row, array &$result) // PATH B: Single-valued association $reflFieldValue = $reflField->getValue($parentObject); - if (! $reflFieldValue || isset($this->_hints[Query::HINT_REFRESH]) || ($reflFieldValue instanceof Proxy && ! $reflFieldValue->__isInitialized())) { + if (! $reflFieldValue || isset($this->_hints[Query::HINT_REFRESH]) || $this->_uow->isUninitializedObject($reflFieldValue)) { // we only need to take action if this value is null, // we refresh the entity or its an uninitialized proxy. if (isset($nonemptyComponents[$dqlAlias])) { diff --git a/lib/Doctrine/ORM/Proxy/InternalProxy.php b/lib/Doctrine/ORM/Proxy/InternalProxy.php new file mode 100644 index 00000000000..baa5703f2a3 --- /dev/null +++ b/lib/Doctrine/ORM/Proxy/InternalProxy.php @@ -0,0 +1,19 @@ + + * + * @method void __setInitialized(bool $initialized) + */ +interface InternalProxy extends Proxy +{ +} diff --git a/lib/Doctrine/ORM/Proxy/Proxy.php b/lib/Doctrine/ORM/Proxy/Proxy.php index 51837a2b973..bc6015d97da 100644 --- a/lib/Doctrine/ORM/Proxy/Proxy.php +++ b/lib/Doctrine/ORM/Proxy/Proxy.php @@ -10,7 +10,11 @@ * Interface for proxy classes. * * @deprecated 2.14. Use \Doctrine\Persistence\Proxy instead + * + * @template T of object + * @template-extends BaseProxy + * @template-extends InternalProxy */ -interface Proxy extends BaseProxy +interface Proxy extends BaseProxy, InternalProxy { } diff --git a/lib/Doctrine/ORM/Proxy/ProxyFactory.php b/lib/Doctrine/ORM/Proxy/ProxyFactory.php index 5f406a05d61..77a49061538 100644 --- a/lib/Doctrine/ORM/Proxy/ProxyFactory.php +++ b/lib/Doctrine/ORM/Proxy/ProxyFactory.php @@ -17,7 +17,6 @@ use Doctrine\ORM\UnitOfWork; use Doctrine\ORM\Utility\IdentifierFlattener; use Doctrine\Persistence\Mapping\ClassMetadata; -use Doctrine\Persistence\Proxy; use ReflectionProperty; use Symfony\Component\VarExporter\ProxyHelper; use Symfony\Component\VarExporter\VarExporter; @@ -101,7 +100,7 @@ public function __construct(EntityManagerInterface $em, $proxyDir, $proxyNs, $au $proxyGenerator = new ProxyGenerator($proxyDir, $proxyNs); if ($em->getConfiguration()->isLazyGhostObjectEnabled()) { - $proxyGenerator->setPlaceholder('baseProxyInterface', Proxy::class); + $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'])); @@ -131,7 +130,7 @@ public function getProxy($className, array $identifier) $initializer = $this->definitions[$className]->initializer; - $proxy->__construct(static function (Proxy $object) use ($initializer, $proxy): void { + $proxy->__construct(static function (InternalProxy $object) use ($initializer, $proxy): void { $initializer($object, $proxy); }); @@ -238,13 +237,13 @@ private function createInitializer(ClassMetadata $classMetadata, EntityPersister /** * Creates a closure capable of initializing a proxy * - * @return Closure(Proxy, Proxy):void + * @return Closure(InternalProxy, InternalProxy):void * * @throws EntityNotFoundException */ private function createLazyInitializer(ClassMetadata $classMetadata, EntityPersister $entityPersister): Closure { - return function (Proxy $proxy, Proxy $original) use ($entityPersister, $classMetadata): void { + return function (InternalProxy $proxy, InternalProxy $original) use ($entityPersister, $classMetadata): void { $identifier = $classMetadata->getIdentifierValues($original); $entity = $entityPersister->loadById($identifier, $original); diff --git a/lib/Doctrine/ORM/Tools/DebugUnitOfWorkListener.php b/lib/Doctrine/ORM/Tools/DebugUnitOfWorkListener.php index 699cf4f6e85..2b01d80af50 100644 --- a/lib/Doctrine/ORM/Tools/DebugUnitOfWorkListener.php +++ b/lib/Doctrine/ORM/Tools/DebugUnitOfWorkListener.php @@ -9,7 +9,6 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\UnitOfWork; -use Doctrine\Persistence\Proxy; use ReflectionObject; use function count; @@ -87,7 +86,7 @@ public function dumpIdentityMap(EntityManagerInterface $em) if ($value === null) { fwrite($fh, " NULL\n"); } else { - if ($value instanceof Proxy && ! $value->__isInitialized()) { + if ($uow->isUninitializedObject($value)) { fwrite($fh, '[PROXY] '); } diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index 34359478b94..a37cdbcb306 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -38,12 +38,12 @@ use Doctrine\ORM\Persisters\Entity\EntityPersister; use Doctrine\ORM\Persisters\Entity\JoinedSubclassPersister; use Doctrine\ORM\Persisters\Entity\SingleTablePersister; +use Doctrine\ORM\Proxy\InternalProxy; use Doctrine\ORM\Utility\IdentifierFlattener; use Doctrine\Persistence\Mapping\RuntimeReflectionService; use Doctrine\Persistence\NotifyPropertyChanged; use Doctrine\Persistence\ObjectManagerAware; use Doctrine\Persistence\PropertyChangedListener; -use Doctrine\Persistence\Proxy; use Exception; use InvalidArgumentException; use RuntimeException; @@ -581,7 +581,7 @@ private function computeSingleEntityChangeSet($entity): void } // Ignore uninitialized proxy objects - if ($entity instanceof Proxy && ! $entity->__isInitialized()) { + if ($this->isUninitializedObject($entity)) { return; } @@ -906,7 +906,7 @@ public function computeChangeSets() foreach ($entitiesToProcess as $entity) { // Ignore uninitialized proxy objects - if ($entity instanceof Proxy && ! $entity->__isInitialized()) { + if ($this->isUninitializedObject($entity)) { continue; } @@ -931,7 +931,7 @@ public function computeChangeSets() */ private function computeAssociationChanges(array $assoc, $value): void { - if ($value instanceof Proxy && ! $value->__isInitialized()) { + if ($this->isUninitializedObject($value)) { return; } @@ -2172,7 +2172,7 @@ private function ensureVersionMatch( $entity, $managedCopy ): void { - if (! ($class->isVersioned && $this->isLoaded($managedCopy) && $this->isLoaded($entity))) { + if (! ($class->isVersioned && ! $this->isUninitializedObject($managedCopy) && ! $this->isUninitializedObject($entity))) { return; } @@ -2190,16 +2190,6 @@ private function ensureVersionMatch( throw OptimisticLockException::lockFailedVersionMismatch($entity, $entityVersion, $managedCopyVersion); } - /** - * Tests if an entity is loaded - must either be a loaded proxy or not a proxy - * - * @param object $entity - */ - private function isLoaded($entity): bool - { - return ! ($entity instanceof Proxy) || $entity->__isInitialized(); - } - /** * Sets/adds associated managed copies into the previous entity's association field * @@ -2495,7 +2485,7 @@ static function ($assoc) { */ private function cascadePersist($entity, array &$visited): void { - if ($entity instanceof Proxy && ! $entity->__isInitialized()) { + if ($this->isUninitializedObject($entity)) { // nothing to do - proxy is not initialized, therefore we don't do anything with it return; } @@ -2569,13 +2559,13 @@ static function ($assoc) { } ); + if ($associationMappings) { + $this->initializeObject($entity); + } + $entitiesToCascade = []; foreach ($associationMappings as $assoc) { - if ($entity instanceof Proxy && ! $entity->__isInitialized()) { - $entity->__load(); - } - $relatedEntities = $class->reflFields[$assoc['fieldName']]->getValue($entity); switch (true) { @@ -2631,9 +2621,7 @@ public function lock($entity, int $lockMode, $lockVersion = null): void return; } - if ($entity instanceof Proxy && ! $entity->__isInitialized()) { - $entity->__load(); - } + $this->initializeObject($entity); assert($class->versionField !== null); $entityVersion = $class->reflFields[$class->versionField]->getValue($entity); @@ -2823,7 +2811,6 @@ public function createEntity($className, array $data, &$hints = []) $unmanagedProxy = $hints[Query::HINT_REFRESH_ENTITY]; if ( $unmanagedProxy !== $entity - && $unmanagedProxy instanceof Proxy && $this->isIdentifierEquals($unmanagedProxy, $entity) ) { // We will hydrate the given un-managed proxy anyway: @@ -2832,7 +2819,7 @@ public function createEntity($className, array $data, &$hints = []) } } - if ($entity instanceof Proxy && ! $entity->__isInitialized()) { + if ($this->isUninitializedObject($entity)) { $entity->__setInitialized(true); } else { if ( @@ -2978,8 +2965,7 @@ public function createEntity($className, array $data, &$hints = []) $hints['fetchMode'][$class->name][$field] === ClassMetadata::FETCH_EAGER && isset($hints[self::HINT_DEFEREAGERLOAD]) && ! $targetClass->isIdentifierComposite && - $newValue instanceof Proxy && - $newValue->__isInitialized() === false + $this->isUninitializedObject($newValue) ) { $this->eagerLoadingEntities[$targetClass->rootEntityName][$relatedIdHash] = current($associatedId); } @@ -3377,7 +3363,7 @@ public function registerManaged($entity, array $id, array $data) $this->addToIdentityMap($entity); - if ($entity instanceof NotifyPropertyChanged && ( ! $entity instanceof Proxy || $entity->__isInitialized())) { + if ($entity instanceof NotifyPropertyChanged && ! $this->isUninitializedObject($entity)) { $entity->addPropertyChangedListener($this); } } @@ -3485,7 +3471,7 @@ public function getScheduledCollectionUpdates() */ public function initializeObject($obj) { - if ($obj instanceof Proxy) { + if ($obj instanceof InternalProxy) { $obj->__load(); return; @@ -3496,6 +3482,18 @@ public function initializeObject($obj) } } + /** + * Tests if a value is an uninitialized entity. + * + * @param mixed $obj + * + * @psalm-assert-if-true InternalProxy $obj + */ + public function isUninitializedObject($obj): bool + { + return $obj instanceof InternalProxy && ! $obj->__isInitialized(); + } + /** * Helper method to show an object as string. * @@ -3646,13 +3644,11 @@ private function assertThatThereAreNoUnintentionallyNonPersistedAssociations(): */ private function mergeEntityStateIntoManagedCopy($entity, $managedCopy): void { - if (! $this->isLoaded($entity)) { + if ($this->isUninitializedObject($entity)) { return; } - if (! $this->isLoaded($managedCopy)) { - $managedCopy->__load(); - } + $this->initializeObject($managedCopy); $class = $this->em->getClassMetadata(get_class($entity)); @@ -3673,7 +3669,7 @@ private function mergeEntityStateIntoManagedCopy($entity, $managedCopy): void if ($other === null) { $prop->setValue($managedCopy, null); } else { - if ($other instanceof Proxy && ! $other->__isInitialized()) { + if ($this->isUninitializedObject($other)) { // do not merge fields marked lazy that have not been fetched. continue; } diff --git a/phpstan-persistence2.neon b/phpstan-persistence2.neon index 394498d9e89..41d9db7a30c 100644 --- a/phpstan-persistence2.neon +++ b/phpstan-persistence2.neon @@ -34,10 +34,5 @@ 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 f4fb6861734..5e439ed0663 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1380,11 +1380,6 @@ $columnList - - - BaseProxy - - $classMetadata @@ -1392,7 +1387,7 @@ $classMetadata - __construct(static function (Proxy $object) use ($initializer, $proxy): void { + __construct(static function (InternalProxy $object) use ($initializer, $proxy): void { $initializer($object, $proxy); })]]> diff --git a/tests/Doctrine/Performance/LazyLoading/ProxyInitializationTimeBench.php b/tests/Doctrine/Performance/LazyLoading/ProxyInitializationTimeBench.php index 6504f8b151a..0befc9eb41e 100644 --- a/tests/Doctrine/Performance/LazyLoading/ProxyInitializationTimeBench.php +++ b/tests/Doctrine/Performance/LazyLoading/ProxyInitializationTimeBench.php @@ -4,9 +4,9 @@ namespace Doctrine\Performance\LazyLoading; +use Doctrine\ORM\Proxy\InternalProxy as Proxy; use Doctrine\Performance\EntityManagerFactory; use Doctrine\Performance\Mock\NonProxyLoadingEntityManager; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\CMS\CmsEmployee; use Doctrine\Tests\Models\CMS\CmsUser; diff --git a/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php b/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php index 812cfc60660..7356a1bfe0b 100644 --- a/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php @@ -8,9 +8,9 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\ORMInvalidArgumentException; use Doctrine\ORM\PersistentCollection; +use Doctrine\ORM\Proxy\InternalProxy; use Doctrine\ORM\Query; use Doctrine\ORM\UnitOfWork; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\IterableTester; use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsArticle; @@ -146,7 +146,7 @@ public function testBasicOneToOne(): void // Address has been eager-loaded because it cant be lazy self::assertInstanceOf(CmsAddress::class, $user2->address); - self::assertNotInstanceOf(Proxy::class, $user2->address); + self::assertFalse($this->isUninitializedObject($user2->address)); } /** @group DDC-1230 */ @@ -530,8 +530,7 @@ public function testSetToOneAssociationWithGetReference(): void // Assume we only got the identifier of the user and now want to attach // the article to the user without actually loading it, using getReference(). $userRef = $this->_em->getReference(CmsUser::class, $user->getId()); - self::assertInstanceOf(Proxy::class, $userRef); - self::assertFalse($userRef->__isInitialized()); + self::assertTrue($this->isUninitializedObject($userRef)); $article = new CmsArticle(); $article->topic = 'topic'; @@ -566,8 +565,7 @@ public function testAddToToManyAssociationWithGetReference(): void // Assume we only got the identifier of the user and now want to attach // the article to the user without actually loading it, using getReference(). $groupRef = $this->_em->getReference(CmsGroup::class, $group->id); - self::assertInstanceOf(Proxy::class, $groupRef); - self::assertFalse($groupRef->__isInitialized()); + self::assertTrue($this->isUninitializedObject($groupRef)); $user = new CmsUser(); $user->name = 'Guilherme'; @@ -745,9 +743,8 @@ public function testQueryEntityByReference(): void ->setParameter('user', $userRef) ->getSingleResult(); - self::assertInstanceOf(Proxy::class, $address2->getUser()); self::assertTrue($userRef === $address2->getUser()); - self::assertFalse($userRef->__isInitialized()); + self::assertTrue($this->isUninitializedObject($userRef)); self::assertEquals('Germany', $address2->country); self::assertEquals('Berlin', $address2->city); self::assertEquals('12345', $address2->zip); @@ -1044,8 +1041,8 @@ public function testManyToOneFetchModeQuery(): void ->setParameter(1, $article->id) ->setFetchMode(CmsArticle::class, 'user', ClassMetadata::FETCH_EAGER) ->getSingleResult(); - self::assertInstanceOf(Proxy::class, $article->user, 'It IS a proxy, ...'); - self::assertTrue($article->user->__isInitialized(), '...but its initialized!'); + self::assertInstanceOf(InternalProxy::class, $article->user, 'It IS a proxy, ...'); + self::assertFalse($this->isUninitializedObject($article->user), '...but its initialized!'); $this->assertQueryCount(2); } diff --git a/tests/Doctrine/Tests/ORM/Functional/ClassTableInheritanceTest.php b/tests/Doctrine/Tests/ORM/Functional/ClassTableInheritanceTest.php index 520f8ddb081..489d9779e67 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ClassTableInheritanceTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ClassTableInheritanceTest.php @@ -7,7 +7,6 @@ use DateTime; use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\PersistentCollection; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\IterableTester; use Doctrine\Tests\Models\Company\CompanyAuction; use Doctrine\Tests\Models\Company\CompanyEmployee; @@ -300,7 +299,7 @@ public function testLazyLoading2(): void $mainEvent = $result[0]->getMainEvent(); // mainEvent should have been loaded because it can't be lazy self::assertInstanceOf(CompanyAuction::class, $mainEvent); - self::assertNotInstanceOf(Proxy::class, $mainEvent); + self::assertFalse($this->isUninitializedObject($mainEvent)); $this->_em->clear(); @@ -432,13 +431,13 @@ public function testGetReferenceEntityWithSubclasses(): void $this->_em->clear(); $ref = $this->_em->getReference(CompanyPerson::class, $manager->getId()); - self::assertNotInstanceOf(Proxy::class, $ref, 'Cannot Request a proxy from a class that has subclasses.'); + self::assertFalse($this->isUninitializedObject($ref), 'Cannot Request a proxy from a class that has subclasses.'); self::assertInstanceOf(CompanyPerson::class, $ref); self::assertInstanceOf(CompanyEmployee::class, $ref, 'Direct fetch of the reference has to load the child class Employee directly.'); $this->_em->clear(); $ref = $this->_em->getReference(CompanyManager::class, $manager->getId()); - self::assertInstanceOf(Proxy::class, $ref, 'A proxy can be generated only if no subclasses exists for the requested reference.'); + self::assertTrue($this->isUninitializedObject($ref), 'A proxy can be generated only if no subclasses exists for the requested reference.'); } /** @group DDC-992 */ diff --git a/tests/Doctrine/Tests/ORM/Functional/DefaultValuesTest.php b/tests/Doctrine/Tests/ORM/Functional/DefaultValuesTest.php index 0c31b494823..313eaa0c30c 100644 --- a/tests/Doctrine/Tests/ORM/Functional/DefaultValuesTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/DefaultValuesTest.php @@ -43,7 +43,7 @@ public function testSimpleDetachMerge(): void $user2 = $this->_em->getReference(get_class($user), $userId); $this->_em->flush(); - self::assertFalse($user2->__isInitialized()); + self::assertTrue($this->isUninitializedObject($user2)); $a = new DefaultValueAddress(); $a->country = 'de'; @@ -55,7 +55,7 @@ public function testSimpleDetachMerge(): void $this->_em->persist($a); $this->_em->flush(); - self::assertFalse($user2->__isInitialized()); + self::assertTrue($this->isUninitializedObject($user2)); $this->_em->clear(); $a2 = $this->_em->find(get_class($a), $a->id); diff --git a/tests/Doctrine/Tests/ORM/Functional/DetachedEntityTest.php b/tests/Doctrine/Tests/ORM/Functional/DetachedEntityTest.php index f1faf5b212f..1fef49dd952 100644 --- a/tests/Doctrine/Tests/ORM/Functional/DetachedEntityTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/DetachedEntityTest.php @@ -6,7 +6,6 @@ use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\OptimisticLockException; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsArticle; use Doctrine\Tests\Models\CMS\CmsPhonenumber; @@ -150,16 +149,13 @@ public function testUninitializedLazyAssociationsAreIgnoredOnMerge(): void $this->_em->clear(); $address2 = $this->_em->find(get_class($address), $address->id); - self::assertInstanceOf(Proxy::class, $address2->user); - self::assertFalse($address2->user->__isInitialized()); + self::assertTrue($this->isUninitializedObject($address2->user)); $detachedAddress2 = unserialize(serialize($address2)); - self::assertInstanceOf(Proxy::class, $detachedAddress2->user); - self::assertFalse($detachedAddress2->user->__isInitialized()); + self::assertTrue($this->isUninitializedObject($detachedAddress2->user)); $managedAddress2 = $this->_em->merge($detachedAddress2); - self::assertInstanceOf(Proxy::class, $managedAddress2->user); self::assertFalse($managedAddress2->user === $detachedAddress2->user); - self::assertFalse($managedAddress2->user->__isInitialized()); + self::assertTrue($this->isUninitializedObject($managedAddress2->user)); } /** @group DDC-822 */ diff --git a/tests/Doctrine/Tests/ORM/Functional/MappedSuperclassTest.php b/tests/Doctrine/Tests/ORM/Functional/MappedSuperclassTest.php index 7163931f30c..03c14f2e03f 100644 --- a/tests/Doctrine/Tests/ORM/Functional/MappedSuperclassTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/MappedSuperclassTest.php @@ -4,7 +4,6 @@ namespace Doctrine\Tests\ORM\Functional; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\DirectoryTree\Directory; use Doctrine\Tests\Models\DirectoryTree\File; use Doctrine\Tests\OrmFunctionalTestCase; @@ -46,7 +45,7 @@ public function testCRUD(): void $cleanFile = $this->_em->find(get_class($file), $file->getId()); self::assertInstanceOf(Directory::class, $cleanFile->getParent()); - self::assertInstanceOf(Proxy::class, $cleanFile->getParent()); + self::assertTrue($this->isUninitializedObject($cleanFile->getParent())); self::assertEquals($directory->getId(), $cleanFile->getParent()->getId()); self::assertInstanceOf(Directory::class, $cleanFile->getParent()->getParent()); self::assertEquals($root->getId(), $cleanFile->getParent()->getParent()->getId()); diff --git a/tests/Doctrine/Tests/ORM/Functional/MergeProxiesTest.php b/tests/Doctrine/Tests/ORM/Functional/MergeProxiesTest.php index 49e6425a5f3..312c5f975ca 100644 --- a/tests/Doctrine/Tests/ORM/Functional/MergeProxiesTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/MergeProxiesTest.php @@ -11,7 +11,6 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\ORMSetup; use Doctrine\ORM\Tools\SchemaTool; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\DbalExtensions\Connection; use Doctrine\Tests\DbalExtensions\QueryLog; use Doctrine\Tests\Models\Generic\DateTimeModel; @@ -48,8 +47,8 @@ public function testMergeDetachedUnInitializedProxy(): void self::assertSame($managed, $this->_em->merge($detachedUninitialized)); - self::assertFalse($managed->__isInitialized()); - self::assertFalse($detachedUninitialized->__isInitialized()); + self::assertTrue($this->isUninitializedObject($managed)); + self::assertTrue($this->isUninitializedObject($detachedUninitialized)); } /** @@ -71,8 +70,8 @@ public function testMergeUnserializedUnInitializedProxy(): void $this->_em->merge(unserialize(serialize($this->_em->merge($detachedUninitialized)))) ); - self::assertFalse($managed->__isInitialized()); - self::assertFalse($detachedUninitialized->__isInitialized()); + self::assertTrue($this->isUninitializedObject($managed)); + self::assertTrue($this->isUninitializedObject($detachedUninitialized)); } /** @@ -87,7 +86,7 @@ public function testMergeManagedProxy(): void self::assertSame($managed, $this->_em->merge($managed)); - self::assertFalse($managed->__isInitialized()); + self::assertTrue($this->isUninitializedObject($managed)); } /** @@ -109,13 +108,12 @@ public function testMergeWithExistingUninitializedManagedProxy(): void $managed = $this->_em->getReference(DateTimeModel::class, $date->id); - self::assertInstanceOf(Proxy::class, $managed); - self::assertFalse($managed->__isInitialized()); + self::assertTrue($this->isUninitializedObject($managed)); $date->date = $dateTime = new DateTime(); self::assertSame($managed, $this->_em->merge($date)); - self::assertTrue($managed->__isInitialized()); + self::assertFalse($this->isUninitializedObject($managed)); self::assertSame($dateTime, $managed->date, 'Data was merged into the proxy after initialization'); } @@ -150,8 +148,8 @@ public function testMergingProxyFromDifferentEntityManagerWithExistingManagedIns self::assertNotSame($proxy1, $merged2); self::assertSame($proxy2, $merged2); - self::assertFalse($proxy1->__isInitialized()); - self::assertFalse($proxy2->__isInitialized()); + self::assertTrue($this->isUninitializedObject($proxy1)); + self::assertTrue($this->isUninitializedObject($proxy2)); $proxy1->__load(); @@ -207,9 +205,8 @@ public function testMergingUnInitializedProxyDoesNotInitializeIt(): void $unManagedProxy = $em1->getReference(DateTimeModel::class, $file1->id); $mergedInstance = $em2->merge($unManagedProxy); - self::assertNotInstanceOf(Proxy::class, $mergedInstance); - self::assertNotSame($unManagedProxy, $mergedInstance); - self::assertFalse($unManagedProxy->__isInitialized()); + self::assertFalse($this->isUninitializedObject($mergedInstance)); + self::assertTrue($this->isUninitializedObject($unManagedProxy)); self::assertCount( 0, diff --git a/tests/Doctrine/Tests/ORM/Functional/OneToManyBidirectionalAssociationTest.php b/tests/Doctrine/Tests/ORM/Functional/OneToManyBidirectionalAssociationTest.php index 5b6db0f93c6..1f8695cd432 100644 --- a/tests/Doctrine/Tests/ORM/Functional/OneToManyBidirectionalAssociationTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/OneToManyBidirectionalAssociationTest.php @@ -6,7 +6,6 @@ use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\ECommerce\ECommerceFeature; use Doctrine\Tests\Models\ECommerce\ECommerceProduct; use Doctrine\Tests\OrmFunctionalTestCase; @@ -90,12 +89,12 @@ public function testEagerLoadsOneToManyAssociation(): void $features = $product->getFeatures(); self::assertInstanceOf(ECommerceFeature::class, $features[0]); - self::assertNotInstanceOf(Proxy::class, $features[0]->getProduct()); + self::assertFalse($this->isUninitializedObject($features[0]->getProduct())); self::assertSame($product, $features[0]->getProduct()); self::assertEquals('Model writing tutorial', $features[0]->getDescription()); self::assertInstanceOf(ECommerceFeature::class, $features[1]); self::assertSame($product, $features[1]->getProduct()); - self::assertNotInstanceOf(Proxy::class, $features[1]->getProduct()); + self::assertFalse($this->isUninitializedObject($features[1]->getProduct())); self::assertEquals('Annotations examples', $features[1]->getDescription()); } @@ -126,11 +125,10 @@ public function testLazyLoadsObjectsOnTheInverseSide(): void $features = $query->getResult(); $product = $features[0]->getProduct(); - self::assertInstanceOf(Proxy::class, $product); self::assertInstanceOf(ECommerceProduct::class, $product); - self::assertFalse($product->__isInitialized()); + self::assertTrue($this->isUninitializedObject($product)); self::assertSame('Doctrine Cookbook', $product->getName()); - self::assertTrue($product->__isInitialized()); + self::assertFalse($this->isUninitializedObject($product)); } public function testLazyLoadsObjectsOnTheInverseSide2(): void @@ -141,7 +139,7 @@ public function testLazyLoadsObjectsOnTheInverseSide2(): void $features = $query->getResult(); $product = $features[0]->getProduct(); - self::assertNotInstanceOf(Proxy::class, $product); + self::assertFalse($this->isUninitializedObject($product)); self::assertInstanceOf(ECommerceProduct::class, $product); self::assertSame('Doctrine Cookbook', $product->getName()); diff --git a/tests/Doctrine/Tests/ORM/Functional/OneToOneBidirectionalAssociationTest.php b/tests/Doctrine/Tests/ORM/Functional/OneToOneBidirectionalAssociationTest.php index fb7199fe4eb..b0896734d53 100644 --- a/tests/Doctrine/Tests/ORM/Functional/OneToOneBidirectionalAssociationTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/OneToOneBidirectionalAssociationTest.php @@ -5,7 +5,6 @@ namespace Doctrine\Tests\ORM\Functional; use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\ECommerce\ECommerceCart; use Doctrine\Tests\Models\ECommerce\ECommerceCustomer; use Doctrine\Tests\OrmFunctionalTestCase; @@ -100,7 +99,7 @@ public function testInverseSideIsNeverLazy(): void self::assertNull($customer->getMentor()); self::assertInstanceOf(ECommerceCart::class, $customer->getCart()); - self::assertNotInstanceOf(Proxy::class, $customer->getCart()); + self::assertFalse($this->isUninitializedObject($customer->getCart())); self::assertEquals('paypal', $customer->getCart()->getPayment()); } diff --git a/tests/Doctrine/Tests/ORM/Functional/OneToOneEagerLoadingTest.php b/tests/Doctrine/Tests/ORM/Functional/OneToOneEagerLoadingTest.php index 9e6c9e1df1e..94a68c19f32 100644 --- a/tests/Doctrine/Tests/ORM/Functional/OneToOneEagerLoadingTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/OneToOneEagerLoadingTest.php @@ -14,7 +14,6 @@ use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Mapping\OneToOne; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; use function get_class; @@ -52,7 +51,7 @@ public function testEagerLoadOneToOneOwningSide(): void $this->getQueryLog()->reset()->enable(); $train = $this->_em->find(get_class($train), $train->id); - self::assertNotInstanceOf(Proxy::class, $train->driver); + self::assertFalse($this->isUninitializedObject($train->driver)); self::assertEquals('Benjamin', $train->driver->name); $this->assertQueryCount(1); @@ -70,7 +69,6 @@ public function testEagerLoadOneToOneNullOwningSide(): void $this->getQueryLog()->reset()->enable(); $train = $this->_em->find(get_class($train), $train->id); - self::assertNotInstanceOf(Proxy::class, $train->driver); self::assertNull($train->driver); $this->assertQueryCount(1); @@ -88,9 +86,9 @@ public function testEagerLoadOneToOneInverseSide(): void $this->getQueryLog()->reset()->enable(); - $driver = $this->_em->find(get_class($owner), $owner->id); - self::assertNotInstanceOf(Proxy::class, $owner->train); - self::assertNotNull($owner->train); + $this->_em->find(get_class($owner), $owner->id); + self::assertFalse($this->isUninitializedObject($owner->train)); + self::assertInstanceOf(Train::class, $owner->train); $this->assertQueryCount(1); } @@ -109,7 +107,6 @@ public function testEagerLoadOneToOneNullInverseSide(): void $this->getQueryLog()->reset()->enable(); $driver = $this->_em->find(get_class($driver), $driver->id); - self::assertNotInstanceOf(Proxy::class, $driver->train); self::assertNull($driver->train); $this->assertQueryCount(1); @@ -126,8 +123,8 @@ public function testEagerLoadManyToOne(): void $this->_em->clear(); $waggon = $this->_em->find(get_class($waggon), $waggon->id); - self::assertNotInstanceOf(Proxy::class, $waggon->train); - self::assertNotNull($waggon->train); + self::assertFalse($this->isUninitializedObject($waggon->train)); + self::assertInstanceOf(Train::class, $waggon->train); } /** @group non-cacheable */ diff --git a/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php b/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php index 6c211aa603c..34b7823713e 100644 --- a/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php @@ -11,7 +11,6 @@ use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\OneToOne; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\ECommerce\ECommerceCustomer; use Doctrine\Tests\OrmFunctionalTestCase; @@ -69,7 +68,7 @@ public function testFind(): void $id = $this->createFixture(); $customer = $this->_em->find(ECommerceCustomer::class, $id); - self::assertNotInstanceOf(Proxy::class, $customer->getMentor()); + self::assertFalse($this->isUninitializedObject($customer->getMentor())); } public function testEagerLoadsAssociation(): void diff --git a/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php b/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php index 24b61d8654e..01f82c8de7d 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php @@ -59,7 +59,7 @@ public function testPersistUpdate(): void { // Considering case (a) $proxy = $this->_em->getProxyFactory()->getProxy(CmsUser::class, ['id' => 123]); - $proxy->__setInitialized(true); + $proxy->id = null; $proxy->username = 'ocra'; $proxy->name = 'Marco'; @@ -84,7 +84,7 @@ public function testEntityWithIdentifier(): void $this->_em->persist($uninitializedProxy); $this->_em->flush(); - self::assertFalse($uninitializedProxy->__isInitialized(), 'Proxy didn\'t get initialized during flush operations'); + self::assertTrue($this->isUninitializedObject($uninitializedProxy), 'Proxy didn\'t get initialized during flush operations'); self::assertEquals($userId, $uninitializedProxy->getId()); $this->_em->remove($uninitializedProxy); $this->_em->flush(); diff --git a/tests/Doctrine/Tests/ORM/Functional/QueryTest.php b/tests/Doctrine/Tests/ORM/Functional/QueryTest.php index 894ab729dbc..791c5c31719 100644 --- a/tests/Doctrine/Tests/ORM/Functional/QueryTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/QueryTest.php @@ -13,7 +13,6 @@ use Doctrine\ORM\Query\Parameter; use Doctrine\ORM\Query\QueryException; use Doctrine\ORM\UnexpectedResultException; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\IterableTester; use Doctrine\Tests\Models\CMS\CmsArticle; use Doctrine\Tests\Models\CMS\CmsPhonenumber; @@ -624,8 +623,7 @@ public function testEntityParameters(): void self::assertEquals(1, count($result)); self::assertInstanceOf(CmsArticle::class, $result[0]); self::assertEquals('dr. dolittle', $result[0]->topic); - self::assertInstanceOf(Proxy::class, $result[0]->user); - self::assertFalse($result[0]->user->__isInitialized()); + self::assertTrue($this->isUninitializedObject($result[0]->user)); } /** @group DDC-952 */ @@ -653,7 +651,7 @@ public function testEnableFetchEagerMode(): void self::assertCount(10, $articles); foreach ($articles as $article) { - self::assertNotInstanceOf(Proxy::class, $article); + self::assertFalse($this->isUninitializedObject($article)); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/ReferenceProxyTest.php b/tests/Doctrine/Tests/ORM/Functional/ReferenceProxyTest.php index 1303099f9be..c0b147dfc56 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ReferenceProxyTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ReferenceProxyTest.php @@ -6,7 +6,7 @@ use Doctrine\Common\Proxy\Proxy as CommonProxy; use Doctrine\Common\Util\ClassUtils; -use Doctrine\Persistence\Proxy; +use Doctrine\ORM\Proxy\InternalProxy; use Doctrine\Tests\Models\Company\CompanyAuction; use Doctrine\Tests\Models\ECommerce\ECommerceProduct; use Doctrine\Tests\Models\ECommerce\ECommerceShipping; @@ -120,9 +120,9 @@ public function testInitializeProxy(): void $entity = $this->_em->getReference(ECommerceProduct::class, $id); assert($entity instanceof ECommerceProduct); - self::assertFalse($entity->__isInitialized(), 'Pre-Condition: Object is unitialized proxy.'); + self::assertTrue($this->isUninitializedObject($entity), 'Pre-Condition: Object is unitialized proxy.'); $this->_em->getUnitOfWork()->initializeObject($entity); - self::assertTrue($entity->__isInitialized(), 'Should be initialized after called UnitOfWork::initializeObject()'); + self::assertFalse($this->isUninitializedObject($entity), 'Should be initialized after called UnitOfWork::initializeObject()'); } /** @group DDC-1163 */ @@ -167,9 +167,9 @@ public function testDoNotInitializeProxyOnGettingTheIdentifier(): void $entity = $this->_em->getReference(ECommerceProduct::class, $id); assert($entity instanceof ECommerceProduct); - self::assertFalse($entity->__isInitialized(), 'Pre-Condition: Object is unitialized proxy.'); + self::assertTrue($this->isUninitializedObject($entity), 'Pre-Condition: Object is unitialized proxy.'); self::assertEquals($id, $entity->getId()); - self::assertFalse($entity->__isInitialized(), "Getting the identifier doesn't initialize the proxy."); + self::assertTrue($this->isUninitializedObject($entity), "Getting the identifier doesn't initialize the proxy."); } /** @group DDC-1625 */ @@ -180,9 +180,9 @@ public function testDoNotInitializeProxyOnGettingTheIdentifierDDC1625(): void $entity = $this->_em->getReference(CompanyAuction::class, $id); assert($entity instanceof CompanyAuction); - self::assertFalse($entity->__isInitialized(), 'Pre-Condition: Object is unitialized proxy.'); + self::assertTrue($this->isUninitializedObject($entity), 'Pre-Condition: Object is unitialized proxy.'); self::assertEquals($id, $entity->getId()); - self::assertFalse($entity->__isInitialized(), "Getting the identifier doesn't initialize the proxy when extending."); + self::assertTrue($this->isUninitializedObject($entity), "Getting the identifier doesn't initialize the proxy when extending."); } public function testDoNotInitializeProxyOnGettingTheIdentifierAndReturnTheRightType(): void @@ -202,10 +202,10 @@ public function testDoNotInitializeProxyOnGettingTheIdentifierAndReturnTheRightT $product = $this->_em->getRepository(ECommerceProduct::class)->find($product->getId()); $entity = $product->getShipping(); - self::assertFalse($entity->__isInitialized(), 'Pre-Condition: Object is unitialized proxy.'); + self::assertTrue($this->isUninitializedObject($entity), 'Pre-Condition: Object is unitialized proxy.'); self::assertEquals($id, $entity->getId()); self::assertSame($id, $entity->getId(), "Check that the id's are the same value, and type."); - self::assertFalse($entity->__isInitialized(), "Getting the identifier doesn't initialize the proxy."); + self::assertTrue($this->isUninitializedObject($entity), "Getting the identifier doesn't initialize the proxy."); } public function testInitializeProxyOnGettingSomethingOtherThanTheIdentifier(): void @@ -215,9 +215,9 @@ public function testInitializeProxyOnGettingSomethingOtherThanTheIdentifier(): v $entity = $this->_em->getReference(ECommerceProduct::class, $id); assert($entity instanceof ECommerceProduct); - self::assertFalse($entity->__isInitialized(), 'Pre-Condition: Object is unitialized proxy.'); + self::assertTrue($this->isUninitializedObject($entity), 'Pre-Condition: Object is unitialized proxy.'); self::assertEquals('Doctrine Cookbook', $entity->getName()); - self::assertTrue($entity->__isInitialized(), 'Getting something other than the identifier initializes the proxy.'); + self::assertFalse($this->isUninitializedObject($entity), 'Getting something other than the identifier initializes the proxy.'); } /** @group DDC-1604 */ @@ -229,8 +229,8 @@ public function testCommonPersistenceProxy(): void assert($entity instanceof ECommerceProduct); $className = ClassUtils::getClass($entity); - self::assertInstanceOf(Proxy::class, $entity); - self::assertFalse($entity->__isInitialized()); + self::assertInstanceOf(InternalProxy::class, $entity); + self::assertTrue($this->isUninitializedObject($entity)); self::assertEquals(ECommerceProduct::class, $className); $restName = str_replace($this->_em->getConfiguration()->getProxyNamespace(), '', get_class($entity)); @@ -239,6 +239,6 @@ public function testCommonPersistenceProxy(): void self::assertTrue(file_exists($proxyFileName), 'Proxy file name cannot be found generically.'); $entity->__load(); - self::assertTrue($entity->__isInitialized()); + self::assertFalse($this->isUninitializedObject($entity)); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php b/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php index 32cac827f08..984cb3e3929 100644 --- a/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php @@ -10,9 +10,9 @@ use Doctrine\ORM\Cache\EntityCacheKey; use Doctrine\ORM\Cache\Exception\CacheException; use Doctrine\ORM\Cache\QueryCacheKey; +use Doctrine\ORM\Proxy\InternalProxy; use Doctrine\ORM\Query; use Doctrine\ORM\Query\ResultSetMapping; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\Cache\Attraction; use Doctrine\Tests\Models\Cache\City; use Doctrine\Tests\Models\Cache\Country; @@ -938,7 +938,7 @@ public function testResolveAssociationCacheEntry(): void self::assertNotNull($state1->getCountry()); $this->assertQueryCount(1); self::assertInstanceOf(State::class, $state1); - self::assertInstanceOf(Proxy::class, $state1->getCountry()); + self::assertInstanceOf(InternalProxy::class, $state1->getCountry()); self::assertEquals($countryName, $state1->getCountry()->getName()); self::assertEquals($stateId, $state1->getId()); @@ -956,7 +956,7 @@ public function testResolveAssociationCacheEntry(): void self::assertNotNull($state2->getCountry()); $this->assertQueryCount(0); self::assertInstanceOf(State::class, $state2); - self::assertInstanceOf(Proxy::class, $state2->getCountry()); + self::assertInstanceOf(InternalProxy::class, $state2->getCountry()); self::assertEquals($countryName, $state2->getCountry()->getName()); self::assertEquals($stateId, $state2->getId()); } @@ -1030,7 +1030,7 @@ public function testResolveToManyAssociationCacheEntry(): void $this->assertQueryCount(1); self::assertInstanceOf(State::class, $state1); - self::assertInstanceOf(Proxy::class, $state1->getCountry()); + self::assertInstanceOf(InternalProxy::class, $state1->getCountry()); self::assertInstanceOf(City::class, $state1->getCities()->get(0)); self::assertInstanceOf(State::class, $state1->getCities()->get(0)->getState()); self::assertSame($state1, $state1->getCities()->get(0)->getState()); @@ -1047,7 +1047,7 @@ public function testResolveToManyAssociationCacheEntry(): void $this->assertQueryCount(0); self::assertInstanceOf(State::class, $state2); - self::assertInstanceOf(Proxy::class, $state2->getCountry()); + self::assertInstanceOf(InternalProxy::class, $state2->getCountry()); self::assertInstanceOf(City::class, $state2->getCities()->get(0)); self::assertInstanceOf(State::class, $state2->getCities()->get(0)->getState()); self::assertSame($state2, $state2->getCities()->get(0)->getState()); diff --git a/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheRepositoryTest.php b/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheRepositoryTest.php index 6a1b5c142da..5ac7ab8242f 100644 --- a/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheRepositoryTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/SecondLevelCacheRepositoryTest.php @@ -4,7 +4,7 @@ namespace Doctrine\Tests\ORM\Functional; -use Doctrine\Persistence\Proxy; +use Doctrine\ORM\Proxy\InternalProxy; use Doctrine\Tests\Models\Cache\Country; use Doctrine\Tests\Models\Cache\State; @@ -197,8 +197,8 @@ public function testRepositoryCacheFindAllToOneAssociation(): void self::assertInstanceOf(State::class, $entities[1]); self::assertInstanceOf(Country::class, $entities[0]->getCountry()); self::assertInstanceOf(Country::class, $entities[0]->getCountry()); - self::assertInstanceOf(Proxy::class, $entities[0]->getCountry()); - self::assertInstanceOf(Proxy::class, $entities[1]->getCountry()); + self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry()); + self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry()); // load from cache $this->getQueryLog()->reset()->enable(); @@ -211,8 +211,8 @@ public function testRepositoryCacheFindAllToOneAssociation(): void self::assertInstanceOf(State::class, $entities[1]); self::assertInstanceOf(Country::class, $entities[0]->getCountry()); self::assertInstanceOf(Country::class, $entities[1]->getCountry()); - self::assertInstanceOf(Proxy::class, $entities[0]->getCountry()); - self::assertInstanceOf(Proxy::class, $entities[1]->getCountry()); + self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry()); + self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry()); // invalidate cache $this->_em->persist(new State('foo', $this->_em->find(Country::class, $this->countries[0]->getId()))); @@ -230,8 +230,8 @@ public function testRepositoryCacheFindAllToOneAssociation(): void self::assertInstanceOf(State::class, $entities[1]); self::assertInstanceOf(Country::class, $entities[0]->getCountry()); self::assertInstanceOf(Country::class, $entities[1]->getCountry()); - self::assertInstanceOf(Proxy::class, $entities[0]->getCountry()); - self::assertInstanceOf(Proxy::class, $entities[1]->getCountry()); + self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry()); + self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry()); // load from cache $this->getQueryLog()->reset()->enable(); @@ -244,7 +244,7 @@ public function testRepositoryCacheFindAllToOneAssociation(): void self::assertInstanceOf(State::class, $entities[1]); self::assertInstanceOf(Country::class, $entities[0]->getCountry()); self::assertInstanceOf(Country::class, $entities[1]->getCountry()); - self::assertInstanceOf(Proxy::class, $entities[0]->getCountry()); - self::assertInstanceOf(Proxy::class, $entities[1]->getCountry()); + self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry()); + self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry()); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/SingleTableInheritanceTest.php b/tests/Doctrine/Tests/ORM/Functional/SingleTableInheritanceTest.php index 953c93e78ac..935b4dd6e43 100644 --- a/tests/Doctrine/Tests/ORM/Functional/SingleTableInheritanceTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/SingleTableInheritanceTest.php @@ -7,7 +7,6 @@ use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Persisters\MatchingAssociationFieldRequiresObject; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\Company\CompanyContract; use Doctrine\Tests\Models\Company\CompanyEmployee; use Doctrine\Tests\Models\Company\CompanyFixContract; @@ -397,13 +396,14 @@ public function testGetReferenceEntityWithSubclasses(): void $this->loadFullFixture(); $ref = $this->_em->getReference(CompanyContract::class, $this->fix->getId()); - self::assertNotInstanceOf(Proxy::class, $ref, 'Cannot Request a proxy from a class that has subclasses.'); + self::assertFalse($this->isUninitializedObject($ref), 'Cannot Request a proxy from a class that has subclasses.'); self::assertInstanceOf(CompanyContract::class, $ref); self::assertInstanceOf(CompanyFixContract::class, $ref, 'Direct fetch of the reference has to load the child class Employee directly.'); $this->_em->clear(); $ref = $this->_em->getReference(CompanyFixContract::class, $this->fix->getId()); - self::assertInstanceOf(Proxy::class, $ref, 'A proxy can be generated only if no subclasses exists for the requested reference.'); + + self::assertTrue($this->isUninitializedObject($ref), 'A proxy can be generated only if no subclasses exists for the requested reference.'); } /** @group DDC-952 */ @@ -417,6 +417,6 @@ public function testEagerLoadInheritanceHierarchy(): void ->setParameter(1, $this->fix->getId()) ->getSingleResult(); - self::assertNotInstanceOf(Proxy::class, $contract->getSalesPerson()); + self::assertFalse($this->isUninitializedObject($contract->getSalesPerson())); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1163Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1163Test.php index 1d1581011fd..dbff2863d4e 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1163Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1163Test.php @@ -15,7 +15,6 @@ use Doctrine\ORM\Mapping\JoinColumns; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToOne; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; use function assert; @@ -92,7 +91,7 @@ private function setPropertyAndAssignTagToSpecialProduct(): void assert($specialProduct instanceof DDC1163SpecialProduct); self::assertInstanceOf(DDC1163SpecialProduct::class, $specialProduct); - self::assertInstanceOf(Proxy::class, $specialProduct); + self::assertTrue($this->isUninitializedObject($specialProduct)); $specialProduct->setSubclassProperty('foobar'); diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1193Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1193Test.php index fcfb7e45e34..cc7967710c4 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1193Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1193Test.php @@ -45,7 +45,7 @@ public function testIssue(): void $company = $this->_em->find(get_class($company), $companyId); self::assertTrue($this->_em->getUnitOfWork()->isInIdentityMap($company), 'Company is in identity map.'); - self::assertFalse($company->member->__isInitialized(), 'Pre-Condition'); + self::assertTrue($this->isUninitializedObject($company->member), 'Pre-Condition'); self::assertTrue($this->_em->getUnitOfWork()->isInIdentityMap($company->member), 'Member is in identity map.'); $this->_em->remove($company); diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1228Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1228Test.php index f8eae7d19f5..d5a95261c12 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1228Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1228Test.php @@ -38,9 +38,9 @@ public function testOneToOnePersist(): void $user = $this->_em->find(DDC1228User::class, $user->id); - self::assertFalse($user->getProfile()->__isInitialized(), 'Proxy is not initialized'); + self::assertTrue($this->isUninitializedObject($user->getProfile()), 'Proxy is not initialized'); $user->getProfile()->setName('Bar'); - self::assertTrue($user->getProfile()->__isInitialized(), 'Proxy is not initialized'); + self::assertFalse($this->isUninitializedObject($user->getProfile()), 'Proxy is not initialized'); self::assertEquals('Bar', $user->getProfile()->getName()); self::assertEquals(['id' => 1, 'name' => 'Foo'], $this->_em->getUnitOfWork()->getOriginalEntityData($user->getProfile())); diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1452Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1452Test.php index a95f9ea22b5..2cd3d1924bf 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1452Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1452Test.php @@ -12,7 +12,6 @@ use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsUser; use Doctrine\Tests\OrmFunctionalTestCase; @@ -54,7 +53,7 @@ public function testIssue(): void $results = $this->_em->createQuery($dql)->setMaxResults(1)->getResult(); self::assertSame($results[0], $results[0]->entitiesB[0]->entityAFrom); - self::assertNotInstanceOf(Proxy::class, $results[0]->entitiesB[0]->entityATo); + self::assertFalse($this->isUninitializedObject($results[0]->entitiesB[0]->entityATo)); self::assertInstanceOf(Collection::class, $results[0]->entitiesB[0]->entityATo->getEntitiesB()); } @@ -82,12 +81,12 @@ public function testFetchJoinOneToOneFromInverse(): void $data = $this->_em->createQuery($dql)->getResult(); $this->_em->clear(); - self::assertNotInstanceOf(Proxy::class, $data[0]->user); + self::assertFalse($this->isUninitializedObject($data[0]->user)); $dql = 'SELECT u, a FROM Doctrine\Tests\Models\CMS\CmsUser u INNER JOIN u.address a'; $data = $this->_em->createQuery($dql)->getResult(); - self::assertNotInstanceOf(Proxy::class, $data[0]->address); + self::assertFalse($this->isUninitializedObject($data[0]->address)); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1690Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1690Test.php index 20af53c3abe..c72081cbc61 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1690Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1690Test.php @@ -12,7 +12,6 @@ use Doctrine\ORM\Mapping\OneToOne; use Doctrine\Persistence\NotifyPropertyChanged; use Doctrine\Persistence\PropertyChangedListener; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; use function count; @@ -57,9 +56,10 @@ public function testChangeTracking(): void $child = $this->_em->find(DDC1690Child::class, $childId); self::assertEquals(1, count($parent->listeners)); - self::assertInstanceOf(Proxy::class, $child, 'Verifying that $child is a proxy before using proxy API'); self::assertCount(0, $child->listeners); - $child->__load(); + + $this->_em->getUnitOfWork()->initializeObject($child); + self::assertCount(1, $child->listeners); unset($parent, $child); diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1734Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1734Test.php index 86bc0adbdf2..35fbffdea88 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1734Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1734Test.php @@ -4,7 +4,7 @@ namespace Doctrine\Tests\ORM\Functional\Ticket; -use Doctrine\Persistence\Proxy; +use Doctrine\ORM\Proxy\InternalProxy; use Doctrine\Tests\Models\CMS\CmsGroup; use Doctrine\Tests\OrmFunctionalTestCase; @@ -37,8 +37,7 @@ public function testMergeWorksOnNonSerializedProxies(): void $proxy = $this->getProxy($group); - self::assertInstanceOf(Proxy::class, $proxy); - self::assertFalse($proxy->__isInitialized()); + self::assertTrue($this->isUninitializedObject($proxy)); $this->_em->detach($proxy); $this->_em->clear(); @@ -67,8 +66,7 @@ public function testMergeWorksOnSerializedProxies(): void $proxy = $this->getProxy($group); - self::assertInstanceOf(Proxy::class, $proxy); - self::assertFalse($proxy->__isInitialized()); + self::assertTrue($this->isUninitializedObject($proxy)); $this->_em->detach($proxy); $serializedProxy = serialize($proxy); @@ -79,7 +77,7 @@ public function testMergeWorksOnSerializedProxies(): void } /** @param object $object */ - private function getProxy($object): Proxy + private function getProxy($object): InternalProxy { $metadataFactory = $this->_em->getMetadataFactory(); $className = get_class($object); diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2230Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2230Test.php index 93c14149a08..8557858ac92 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2230Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2230Test.php @@ -12,7 +12,6 @@ use Doctrine\ORM\Mapping\OneToOne; use Doctrine\Persistence\NotifyPropertyChanged; use Doctrine\Persistence\PropertyChangedListener; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; use function assert; @@ -47,10 +46,8 @@ public function testNotifyTrackingNotCalledOnUninitializedProxies(): void $mergedUser = $this->_em->merge($user); $address = $mergedUser->address; - assert($address instanceof Proxy); - self::assertInstanceOf(Proxy::class, $address); - self::assertFalse($address->__isInitialized()); + self::assertTrue($this->isUninitializedObject($address)); } public function testNotifyTrackingCalledOnProxyInitialization(): void @@ -62,12 +59,12 @@ public function testNotifyTrackingCalledOnProxyInitialization(): void $this->_em->clear(); $addressProxy = $this->_em->getReference(DDC2230Address::class, $insertedAddress->id); - assert($addressProxy instanceof Proxy || $addressProxy instanceof DDC2230Address); + assert($addressProxy instanceof DDC2230Address); - self::assertFalse($addressProxy->__isInitialized()); + self::assertTrue($this->isUninitializedObject($addressProxy)); self::assertNull($addressProxy->listener); - $addressProxy->__load(); + $this->_em->getUnitOfWork()->initializeObject($addressProxy); self::assertSame($this->_em->getUnitOfWork(), $addressProxy->listener); } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2231Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2231Test.php index 8ef946fabd2..1f8a3c604e7 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2231Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2231Test.php @@ -12,7 +12,6 @@ use Doctrine\Persistence\Mapping\ClassMetadata; use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManagerAware; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; use function get_class; @@ -43,12 +42,11 @@ public function testInjectObjectManagerInProxyIfInitializedInUow(): void $y1ref = $this->_em->getReference(get_class($y1), $y1->id); - self::assertInstanceOf(Proxy::class, $y1ref); - self::assertFalse($y1ref->__isInitialized()); + self::assertTrue($this->isUninitializedObject($y1ref)); $id = $y1ref->doSomething(); - self::assertTrue($y1ref->__isInitialized()); + self::assertFalse($this->isUninitializedObject($y1ref)); self::assertEquals($this->_em, $y1ref->om); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2306Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2306Test.php index 60acc5a59dc..0d251fe6ffb 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2306Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2306Test.php @@ -12,7 +12,6 @@ use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; use function assert; @@ -63,16 +62,15 @@ public function testIssue(): void $address = $this->_em->find(DDC2306Address::class, $address->id); assert($address instanceof DDC2306Address); $user = $address->users->first()->user; - assert($user instanceof DDC2306User || $user instanceof Proxy); - self::assertInstanceOf(Proxy::class, $user); + $this->assertTrue($this->isUninitializedObject($user)); self::assertInstanceOf(DDC2306User::class, $user); $userId = $user->id; self::assertNotNull($userId); - $user->__load(); + $this->_em->getUnitOfWork()->initializeObject($user); self::assertEquals( $userId, diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC237Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC237Test.php index 1899cbed5ca..ed5e7bff1c0 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC237Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC237Test.php @@ -11,7 +11,6 @@ use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\OneToOne; use Doctrine\ORM\Mapping\Table; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; use function get_class; @@ -50,16 +49,14 @@ public function testUninitializedProxyIsInitializedOnFetchJoin(): void $this->_em->clear(); $x2 = $this->_em->find(get_class($x), $x->id); // proxy injected for Y - self::assertInstanceOf(Proxy::class, $x2->y); - self::assertFalse($x2->y->__isInitialized()); + self::assertTrue($this->isUninitializedObject($x2->y)); // proxy for Y is in identity map $z2 = $this->_em->createQuery('select z,y from ' . get_class($z) . ' z join z.y y where z.id = ?1') ->setParameter(1, $z->id) ->getSingleResult(); - self::assertInstanceOf(Proxy::class, $z2->y); - self::assertTrue($z2->y->__isInitialized()); + self::assertFalse($this->isUninitializedObject($z2->y)); self::assertEquals('Y', $z2->y->data); self::assertEquals($y->id, $z2->y->id); @@ -69,7 +66,6 @@ public function testUninitializedProxyIsInitializedOnFetchJoin(): void self::assertNotSame($x, $x2); self::assertNotSame($z, $z2); self::assertSame($z2->y, $x2->y); - self::assertInstanceOf(Proxy::class, $z2->y); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2494Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2494Test.php index 6cf49a70670..b785a58a463 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2494Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2494Test.php @@ -15,7 +15,6 @@ use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Mapping\Table; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; /** @@ -61,21 +60,20 @@ public function testIssue(): void $this->getQueryLog()->reset()->enable(); - self::assertInstanceOf(Proxy::class, $item->getCurrency()); - self::assertFalse($item->getCurrency()->__isInitialized()); + self::assertTrue($this->isUninitializedObject($item->getCurrency())); self::assertArrayHasKey('convertToPHPValue', DDC2494TinyIntType::$calls); self::assertCount(1, DDC2494TinyIntType::$calls['convertToPHPValue']); self::assertIsInt($item->getCurrency()->getId()); self::assertCount(1, DDC2494TinyIntType::$calls['convertToPHPValue']); - self::assertFalse($item->getCurrency()->__isInitialized()); + self::assertTrue($this->isUninitializedObject($item->getCurrency())); $this->assertQueryCount(0); self::assertIsInt($item->getCurrency()->getTemp()); self::assertCount(3, DDC2494TinyIntType::$calls['convertToPHPValue']); - self::assertTrue($item->getCurrency()->__isInitialized()); + self::assertFalse($this->isUninitializedObject($item->getCurrency())); $this->assertQueryCount(1); } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2519Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2519Test.php index ee8bf3cfa08..0fa9401c728 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2519Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2519Test.php @@ -4,7 +4,6 @@ namespace Doctrine\Tests\ORM\Functional\Ticket; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\Legacy\LegacyUser; use Doctrine\Tests\Models\Legacy\LegacyUserReference; use Doctrine\Tests\OrmFunctionalTestCase; @@ -36,15 +35,10 @@ public function testIssue(): void self::assertInstanceOf(LegacyUser::class, $result[1]->source()); self::assertInstanceOf(LegacyUser::class, $result[1]->target()); - self::assertInstanceOf(Proxy::class, $result[0]->source()); - self::assertInstanceOf(Proxy::class, $result[0]->target()); - self::assertInstanceOf(Proxy::class, $result[1]->source()); - self::assertInstanceOf(Proxy::class, $result[1]->target()); - - self::assertFalse($result[0]->target()->__isInitialized()); - self::assertFalse($result[0]->source()->__isInitialized()); - self::assertFalse($result[1]->target()->__isInitialized()); - self::assertFalse($result[1]->source()->__isInitialized()); + self::assertTrue($this->isUninitializedObject($result[0]->target())); + self::assertTrue($this->isUninitializedObject($result[0]->source())); + self::assertTrue($this->isUninitializedObject($result[1]->target())); + self::assertTrue($this->isUninitializedObject($result[1]->source())); self::assertNotNull($result[0]->source()->getId()); self::assertNotNull($result[0]->target()->getId()); diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC371Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC371Test.php index 828a7adcc2f..89a42dfbce7 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC371Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC371Test.php @@ -14,7 +14,6 @@ use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Query; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; /** @group DDC-371 */ @@ -51,7 +50,7 @@ public function testIssue(): void ->getResult(); self::assertCount(1, $children); - self::assertNotInstanceOf(Proxy::class, $children[0]->parent); + self::assertFalse($this->isUninitializedObject($children[0]->parent)); self::assertFalse($children[0]->parent->children->isInitialized()); self::assertEquals(0, $children[0]->parent->children->unwrap()->count()); } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC522Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC522Test.php index 0616f96ce1c..7d9bdbba644 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC522Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC522Test.php @@ -10,7 +10,6 @@ use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\OneToOne; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; use function get_class; @@ -52,7 +51,7 @@ public function testJoinColumnWithSameNameAsAssociationField(): void self::assertInstanceOf(DDC522Cart::class, $r[0]); self::assertInstanceOf(DDC522Customer::class, $r[0]->customer); - self::assertNotInstanceOf(Proxy::class, $r[0]->customer); + self::assertFalse($this->isUninitializedObject($r[0]->customer)); self::assertEquals('name', $r[0]->customer->name); $fkt = new DDC522ForeignKeyTest(); @@ -64,8 +63,7 @@ public function testJoinColumnWithSameNameAsAssociationField(): void $fkt2 = $this->_em->find(get_class($fkt), $fkt->id); self::assertEquals($fkt->cart->id, $fkt2->cartId); - self::assertInstanceOf(Proxy::class, $fkt2->cart); - self::assertFalse($fkt2->cart->__isInitialized()); + self::assertTrue($this->isUninitializedObject($fkt2->cart)); } /** diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC531Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC531Test.php index e4981161331..5c9a4bfba92 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC531Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC531Test.php @@ -16,7 +16,6 @@ use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; class DDC531Test extends OrmFunctionalTestCase @@ -46,7 +45,7 @@ public function testIssue(): void // parent will already be loaded, cannot be lazy because it has mapped subclasses and we would not // know which proxy type to put in. self::assertInstanceOf(DDC531Item::class, $item3->parent); - self::assertNotInstanceOf(Proxy::class, $item3->parent); + self::assertFalse($this->isUninitializedObject($item3->parent)); $item4 = $this->_em->find(DDC531Item::class, $item1->id); // Load parent item (id 1) self::assertNull($item4->parent); self::assertNotNull($item4->getChildren()); diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC633Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC633Test.php index 827021926ab..a6d77040074 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC633Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC633Test.php @@ -9,7 +9,6 @@ use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\OneToOne; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; class DDC633Test extends OrmFunctionalTestCase @@ -44,7 +43,7 @@ public function testOneToOneEager(): void $eagerAppointment = $this->_em->find(DDC633Appointment::class, $app->id); // Eager loading of one to one leads to fetch-join - self::assertNotInstanceOf(Proxy::class, $eagerAppointment->patient); + self::assertFalse($this->isUninitializedObject($eagerAppointment->patient)); self::assertTrue($this->_em->contains($eagerAppointment->patient)); } @@ -70,8 +69,7 @@ public function testDQLDeferredEagerLoad(): void $appointments = $this->_em->createQuery('SELECT a FROM ' . __NAMESPACE__ . '\DDC633Appointment a')->getResult(); foreach ($appointments as $eagerAppointment) { - self::assertInstanceOf(Proxy::class, $eagerAppointment->patient); - self::assertTrue($eagerAppointment->patient->__isInitialized(), 'Proxy should already be initialized due to eager loading!'); + self::assertFalse($this->isUninitializedObject($eagerAppointment->patient), 'Proxy should already be initialized due to eager loading!'); } } } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC6460Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC6460Test.php index 379e6f090e9..85680311845 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC6460Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC6460Test.php @@ -12,7 +12,6 @@ use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\ManyToOne; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; class DDC6460Test extends OrmFunctionalTestCase @@ -61,11 +60,10 @@ public function testInlineEmbeddableProxyInitialization(): void $secondEntityWithLazyParameter = $this->_em->getRepository(DDC6460ParentEntity::class)->findOneById(1); - self::assertInstanceOf(Proxy::class, $secondEntityWithLazyParameter->lazyLoaded); self::assertInstanceOf(DDC6460Entity::class, $secondEntityWithLazyParameter->lazyLoaded); - self::assertFalse($secondEntityWithLazyParameter->lazyLoaded->__isInitialized()); + self::assertTrue($this->isUninitializedObject($secondEntityWithLazyParameter->lazyLoaded)); self::assertEquals($secondEntityWithLazyParameter->lazyLoaded->embedded, $entity->embedded); - self::assertTrue($secondEntityWithLazyParameter->lazyLoaded->__isInitialized()); + self::assertFalse($this->isUninitializedObject($secondEntityWithLazyParameter->lazyLoaded)); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC736Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC736Test.php index 02d0b69681a..b081651d66d 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC736Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC736Test.php @@ -8,7 +8,6 @@ use Doctrine\ORM\Query\AST; use Doctrine\ORM\Query\AST\SelectExpression; use Doctrine\ORM\Query\TreeWalkerAdapter; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\Models\ECommerce\ECommerceCart; use Doctrine\Tests\Models\ECommerce\ECommerceCustomer; use Doctrine\Tests\OrmFunctionalTestCase; @@ -46,7 +45,7 @@ public function testReorderEntityFetchJoinForHydration(): void unset($result[0]); self::assertInstanceOf(ECommerceCart::class, $cart2); - self::assertNotInstanceOf(Proxy::class, $cart2->getCustomer()); + self::assertFalse($this->isUninitializedObject($cart2->getCustomer())); self::assertInstanceOf(ECommerceCustomer::class, $cart2->getCustomer()); self::assertEquals(['name' => 'roman', 'payment' => 'cash'], $result); } @@ -77,7 +76,7 @@ public function testDqlTreeWalkerReordering(): void $cart2 = $result[0][0]; assert($cart2 instanceof ECommerceCart); - self::assertInstanceOf(Proxy::class, $cart2->getCustomer()); + self::assertTrue($this->isUninitializedObject($cart2->getCustomer())); } } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC881Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC881Test.php index 0d3c2a7592e..138c3c0914c 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC881Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC881Test.php @@ -15,7 +15,6 @@ use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\PersistentCollection; -use Doctrine\Persistence\Proxy; use Doctrine\Tests\OrmFunctionalTestCase; class DDC881Test extends OrmFunctionalTestCase @@ -90,8 +89,8 @@ public function testIssue(): void $calls = $this->_em->createQuery($dql)->getResult(); self::assertCount(2, $calls); - self::assertNotInstanceOf(Proxy::class, $calls[0]->getPhoneNumber()); - self::assertNotInstanceOf(Proxy::class, $calls[1]->getPhoneNumber()); + self::assertFalse($this->isUninitializedObject($calls[0]->getPhoneNumber())); + self::assertFalse($this->isUninitializedObject($calls[1]->getPhoneNumber())); $dql = 'SELECT p, c FROM ' . DDC881PhoneNumber::class . ' p JOIN p.calls c'; $numbers = $this->_em->createQuery($dql)->getResult(); diff --git a/tests/Doctrine/Tests/OrmFunctionalTestCase.php b/tests/Doctrine/Tests/OrmFunctionalTestCase.php index 368e2b6f71b..b3b842f409e 100644 --- a/tests/Doctrine/Tests/OrmFunctionalTestCase.php +++ b/tests/Doctrine/Tests/OrmFunctionalTestCase.php @@ -967,4 +967,10 @@ protected function dropAndCreateTable(Table $table): void $this->dropTableIfExists($tableName); $schemaManager->createTable($table); } + + /** @param object $entity */ + final protected function isUninitializedObject($entity): bool + { + return $this->_em->getUnitOfWork()->isUninitializedObject($entity); + } } From 5213228a649a7a982ce34c8fa30be6f2a42834aa Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Sun, 16 Jul 2023 23:38:29 +0200 Subject: [PATCH 60/72] PHPStan 1.10.25, Psalm 5.13.1 (#10842) --- composer.json | 4 ++-- psalm-baseline.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 73b43b6dc60..a897bbbf8fd 100644 --- a/composer.json +++ b/composer.json @@ -42,14 +42,14 @@ "doctrine/annotations": "^1.13 || ^2", "doctrine/coding-standard": "^9.0.2 || ^12.0", "phpbench/phpbench": "^0.16.10 || ^1.0", - "phpstan/phpstan": "~1.4.10 || 1.10.18", + "phpstan/phpstan": "~1.4.10 || 1.10.25", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6", "psr/log": "^1 || ^2 || ^3", "squizlabs/php_codesniffer": "3.7.2", "symfony/cache": "^4.4 || ^5.4 || ^6.0", "symfony/var-exporter": "^4.4 || ^5.4 || ^6.2", "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0", - "vimeo/psalm": "4.30.0 || 5.13.0" + "vimeo/psalm": "4.30.0 || 5.13.1" }, "conflict": { "doctrine/annotations": "<1.13 || >= 3.0" diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 37cd0acd5eb..44fdb3a4995 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + IterableResult From d3cf17b26dee99b3b5e4e9f1583e3eb9e535c77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 18 Jul 2023 08:49:20 +0200 Subject: [PATCH 61/72] Remove toc We already have the sidebar for this. --- docs/en/index.rst | 6 ++-- docs/en/toc.rst | 86 ----------------------------------------------- 2 files changed, 2 insertions(+), 90 deletions(-) delete mode 100644 docs/en/toc.rst diff --git a/docs/en/index.rst b/docs/en/index.rst index 5fd837f8274..a7979cb1a70 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -18,8 +18,8 @@ Doctrine ORM don't panic. You can get help from different sources: - Report a bug on `GitHub `_. - On `StackOverflow `_ -If you need more structure over the different topics you can browse the :doc:`table -of contents `. +If you need more structure over the different topics you can browse the table +of contents. Getting Started --------------- @@ -124,5 +124,3 @@ Cookbook * **Custom Datatypes** :doc:`MySQL Enums ` :doc:`Advanced Field Value Conversion ` - -.. include:: toc.rst diff --git a/docs/en/toc.rst b/docs/en/toc.rst deleted file mode 100644 index fa92cf38021..00000000000 --- a/docs/en/toc.rst +++ /dev/null @@ -1,86 +0,0 @@ -Welcome to Doctrine 2 ORM's documentation! -========================================== - -Tutorials ---------- - -.. toctree:: - :maxdepth: 1 - - tutorials/getting-started - tutorials/getting-started-database - tutorials/getting-started-models - tutorials/working-with-indexed-associations - tutorials/extra-lazy-associations - tutorials/composite-primary-keys - tutorials/ordered-associations - tutorials/override-field-association-mappings-in-subclasses - tutorials/pagination.rst - tutorials/embeddables.rst - -Reference Guide ---------------- - -.. toctree:: - :maxdepth: 1 - :numbered: - - reference/architecture - reference/configuration.rst - reference/faq - reference/basic-mapping - reference/association-mapping - reference/inheritance-mapping - reference/working-with-objects - reference/working-with-associations - reference/events - reference/unitofwork - reference/unitofwork-associations - reference/transactions-and-concurrency - reference/batch-processing - reference/dql-doctrine-query-language - reference/query-builder - reference/native-sql - reference/change-tracking-policies - reference/partial-objects - reference/annotations-reference - reference/attributes-reference - reference/xml-mapping - reference/yaml-mapping - reference/php-mapping - reference/caching - reference/improving-performance - reference/tools - reference/metadata-drivers - reference/best-practices - reference/limitations-and-known-issues - tutorials/pagination - reference/filters - reference/namingstrategy - reference/advanced-configuration - reference/second-level-cache - reference/security - - -Cookbook --------- - -.. toctree:: - :maxdepth: 1 - - cookbook/aggregate-fields - cookbook/custom-mapping-types - cookbook/decorator-pattern - cookbook/dql-custom-walkers - cookbook/dql-user-defined-functions - cookbook/implementing-arrayaccess-for-domain-objects - cookbook/implementing-the-notify-changetracking-policy - cookbook/resolve-target-entity-listener - cookbook/sql-table-prefixes - cookbook/strategy-cookbook-introduction - cookbook/validation-of-entities - cookbook/working-with-datetime - cookbook/mysql-enums - cookbook/advanced-field-value-conversion-using-custom-mapping-types - cookbook/entities-in-session - From 6b0afdbd58be5afea1d9f93b445643e842a79ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Tue, 18 Jul 2023 08:57:09 +0200 Subject: [PATCH 62/72] Avoid triple colons It confuses the guides, and is ugly. --- docs/en/tutorials/getting-started.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/en/tutorials/getting-started.rst b/docs/en/tutorials/getting-started.rst index 023d89c211e..d6c4c816302 100644 --- a/docs/en/tutorials/getting-started.rst +++ b/docs/en/tutorials/getting-started.rst @@ -1343,8 +1343,7 @@ means the join details have already been defined on the owning side. Therefore we only have to specify the property on the Bug class that holds the owning sides. -Update your database schema by running: -:: +Update your database schema by running:: $ php bin/doctrine orm:schema-tool:update --force From 89250b8ca2ff0ac252b2e40ac7ed83a6bb311ea3 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 20 Jul 2023 20:37:52 +0200 Subject: [PATCH 63/72] Use properties instead of getters to read property/class names via reflection (#10848) --- lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php | 4 ++-- lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php | 4 ++-- lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php | 4 ++-- .../ORM/Mapping/Driver/ReflectionBasedDriver.php | 2 +- .../Mapping/Reflection/ReflectionPropertiesGetter.php | 10 +++++----- .../ORM/Mapping/ReflectionEmbeddedProperty.php | 2 +- lib/Doctrine/ORM/Mapping/ReflectionEnumProperty.php | 6 +++--- lib/Doctrine/ORM/Proxy/ProxyFactory.php | 6 +++--- lib/Doctrine/ORM/Tools/EntityGenerator.php | 2 +- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php index b3167948cb8..9d8f27cd1a8 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php @@ -1176,7 +1176,7 @@ public function initializeReflection($reflService) $this->namespace = $reflService->getClassNamespace($this->name); if ($this->reflClass) { - $this->name = $this->rootEntityName = $this->reflClass->getName(); + $this->name = $this->rootEntityName = $this->reflClass->name; } $this->table['name'] = $this->namingStrategy->classToTableName($this->name); @@ -3866,7 +3866,7 @@ private function getAccessibleProperty(ReflectionService $reflService, string $c { $reflectionProperty = $reflService->getAccessibleProperty($class, $field); if ($reflectionProperty !== null && PHP_VERSION_ID >= 80100 && $reflectionProperty->isReadOnly()) { - $declaringClass = $reflectionProperty->getDeclaringClass()->name; + $declaringClass = $reflectionProperty->class; if ($declaringClass !== $class) { $reflectionProperty = $reflService->getAccessibleProperty($declaringClass, $field); } diff --git a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php index 8fdb4495b0f..24ffa0d4168 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php @@ -365,7 +365,7 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad } $mapping = []; - $mapping['fieldName'] = $property->getName(); + $mapping['fieldName'] = $property->name; // Evaluate @Cache annotation $cacheAnnot = $this->reader->getPropertyAnnotation($property, Mapping\Cache::class); @@ -398,7 +398,7 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad // @Column, @OneToOne, @OneToMany, @ManyToOne, @ManyToMany $columnAnnot = $this->reader->getPropertyAnnotation($property, Mapping\Column::class); if ($columnAnnot) { - $mapping = $this->columnToArray($property->getName(), $columnAnnot); + $mapping = $this->columnToArray($property->name, $columnAnnot); $idAnnot = $this->reader->getPropertyAnnotation($property, Mapping\Id::class); if ($idAnnot) { diff --git a/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php b/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php index 4ad46928dfd..1e14836833e 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php @@ -315,7 +315,7 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad } $mapping = []; - $mapping['fieldName'] = $property->getName(); + $mapping['fieldName'] = $property->name; // Evaluate #[Cache] attribute $cacheAttribute = $this->reader->getPropertyAttribute($property, Mapping\Cache::class); @@ -350,7 +350,7 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad $embeddedAttribute = $this->reader->getPropertyAttribute($property, Mapping\Embedded::class); if ($columnAttribute !== null) { - $mapping = $this->columnToArray($property->getName(), $columnAttribute); + $mapping = $this->columnToArray($property->name, $columnAttribute); if ($this->reader->getPropertyAttribute($property, Mapping\Id::class)) { $mapping['id'] = true; diff --git a/lib/Doctrine/ORM/Mapping/Driver/ReflectionBasedDriver.php b/lib/Doctrine/ORM/Mapping/Driver/ReflectionBasedDriver.php index e42aab7b9cc..a2900d13e01 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/ReflectionBasedDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/ReflectionBasedDriver.php @@ -32,7 +32,7 @@ private function isRepeatedPropertyDeclaration(ReflectionProperty $property, Cla || $metadata->isInheritedEmbeddedClass($property->name); } - $declaringClass = $property->getDeclaringClass()->getName(); + $declaringClass = $property->class; if ( isset($metadata->fieldMappings[$property->name]['declared']) diff --git a/lib/Doctrine/ORM/Mapping/Reflection/ReflectionPropertiesGetter.php b/lib/Doctrine/ORM/Mapping/Reflection/ReflectionPropertiesGetter.php index 11b41ea9332..b6a289d5e79 100644 --- a/lib/Doctrine/ORM/Mapping/Reflection/ReflectionPropertiesGetter.php +++ b/lib/Doctrine/ORM/Mapping/Reflection/ReflectionPropertiesGetter.php @@ -73,7 +73,7 @@ private function getHierarchyClasses(string $className): array $parentClass = $currentClass->getParentClass(); if ($parentClass) { - $parentClassName = $parentClass->getName(); + $parentClassName = $parentClass->name; } } @@ -111,14 +111,14 @@ private function isInstanceProperty(ReflectionProperty $reflectionProperty): boo private function getAccessibleProperty(ReflectionProperty $property): ?ReflectionProperty { return $this->reflectionService->getAccessibleProperty( - $property->getDeclaringClass()->getName(), - $property->getName() + $property->class, + $property->name ); } private function getLogicalName(ReflectionProperty $property): string { - $propertyName = $property->getName(); + $propertyName = $property->name; if ($property->isPublic()) { return $propertyName; @@ -128,6 +128,6 @@ private function getLogicalName(ReflectionProperty $property): string return "\0*\0" . $propertyName; } - return "\0" . $property->getDeclaringClass()->getName() . "\0" . $propertyName; + return "\0" . $property->class . "\0" . $propertyName; } } diff --git a/lib/Doctrine/ORM/Mapping/ReflectionEmbeddedProperty.php b/lib/Doctrine/ORM/Mapping/ReflectionEmbeddedProperty.php index 563a699480f..db4c1f868a3 100644 --- a/lib/Doctrine/ORM/Mapping/ReflectionEmbeddedProperty.php +++ b/lib/Doctrine/ORM/Mapping/ReflectionEmbeddedProperty.php @@ -38,7 +38,7 @@ public function __construct(ReflectionProperty $parentProperty, ReflectionProper $this->childProperty = $childProperty; $this->embeddedClass = (string) $embeddedClass; - parent::__construct($childProperty->getDeclaringClass()->getName(), $childProperty->getName()); + parent::__construct($childProperty->class, $childProperty->name); } /** diff --git a/lib/Doctrine/ORM/Mapping/ReflectionEnumProperty.php b/lib/Doctrine/ORM/Mapping/ReflectionEnumProperty.php index a162ebfd345..3283a54ab0d 100644 --- a/lib/Doctrine/ORM/Mapping/ReflectionEnumProperty.php +++ b/lib/Doctrine/ORM/Mapping/ReflectionEnumProperty.php @@ -28,8 +28,8 @@ public function __construct(ReflectionProperty $originalReflectionProperty, stri $this->enumType = $enumType; parent::__construct( - $originalReflectionProperty->getDeclaringClass()->getName(), - $originalReflectionProperty->getName() + $originalReflectionProperty->class, + $originalReflectionProperty->name ); } @@ -98,7 +98,7 @@ private function initializeEnumValue($object, $value): BackedEnum } catch (ValueError $e) { throw MappingException::invalidEnumValue( get_class($object), - $this->originalReflectionProperty->getName(), + $this->originalReflectionProperty->name, (string) $value, $enumType, $e diff --git a/lib/Doctrine/ORM/Proxy/ProxyFactory.php b/lib/Doctrine/ORM/Proxy/ProxyFactory.php index 77a49061538..63c925861c4 100644 --- a/lib/Doctrine/ORM/Proxy/ProxyFactory.php +++ b/lib/Doctrine/ORM/Proxy/ProxyFactory.php @@ -335,13 +335,13 @@ private function generateSkippedProperties(ClassMetadata $class): string 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; } @@ -376,7 +376,7 @@ private function generateSerializeImpl(ClassMetadata $class): string 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; + $value = $properties[$k = $name] ?? $properties[$k = "\0*\0$name"] ?? $properties[$k = "\0' . $reflector->name . '\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); diff --git a/lib/Doctrine/ORM/Tools/EntityGenerator.php b/lib/Doctrine/ORM/Tools/EntityGenerator.php index 34f4eba6258..5bf086d52b5 100644 --- a/lib/Doctrine/ORM/Tools/EntityGenerator.php +++ b/lib/Doctrine/ORM/Tools/EntityGenerator.php @@ -969,7 +969,7 @@ protected function getClassToExtendName() { $refl = new ReflectionClass($this->getClassToExtend()); - return '\\' . $refl->getName(); + return '\\' . $refl->name; } /** @return string */ From db51ed4f4c2f6bb4709ac5bc8859d1ebd9f22ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 25 Jul 2023 16:23:46 +0200 Subject: [PATCH 64/72] fix: use platform options instead of deprecated custom options (#10855) --- lib/Doctrine/ORM/Tools/SchemaTool.php | 4 ++-- tests/Doctrine/Tests/ORM/Tools/SchemaToolTest.php | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/Doctrine/ORM/Tools/SchemaTool.php b/lib/Doctrine/ORM/Tools/SchemaTool.php index dc0fbc53e78..2e02279cc0c 100644 --- a/lib/Doctrine/ORM/Tools/SchemaTool.php +++ b/lib/Doctrine/ORM/Tools/SchemaTool.php @@ -821,8 +821,8 @@ private function gatherColumnOptions(array $mapping): array return []; } - $options = array_intersect_key($mappingOptions, array_flip(self::KNOWN_COLUMN_OPTIONS)); - $options['customSchemaOptions'] = array_diff_key($mappingOptions, $options); + $options = array_intersect_key($mappingOptions, array_flip(self::KNOWN_COLUMN_OPTIONS)); + $options['platformOptions'] = array_diff_key($mappingOptions, $options); return $options; } diff --git a/tests/Doctrine/Tests/ORM/Tools/SchemaToolTest.php b/tests/Doctrine/Tests/ORM/Tools/SchemaToolTest.php index 08305b243dd..83f70e85965 100644 --- a/tests/Doctrine/Tests/ORM/Tools/SchemaToolTest.php +++ b/tests/Doctrine/Tests/ORM/Tools/SchemaToolTest.php @@ -77,7 +77,7 @@ public function testAnnotationOptionsAttribute(): void ); $table = $schema->getTable('TestEntityWithAnnotationOptionsAttribute'); - foreach ([$table->getOptions(), $table->getColumn('test')->getCustomSchemaOptions()] as $options) { + foreach ([$table->getOptions(), $table->getColumn('test')->getPlatformOptions()] as $options) { self::assertArrayHasKey('foo', $options); self::assertSame('bar', $options['foo']); self::assertArrayHasKey('baz', $options); @@ -139,7 +139,7 @@ public function testPassColumnOptionsToJoinColumn(): void self::assertEquals( ['collation' => 'latin1_bin', 'foo' => 'bar'], - $tableBoard->getColumn('category_id')->getCustomSchemaOptions() + $tableBoard->getColumn('category_id')->getPlatformOptions() ); } @@ -192,13 +192,13 @@ public function testEnumTypeAddedToCustomSchemaOptions(): void $em = $this->getTestEntityManager(); $schemaTool = new SchemaTool($em); - $customSchemaOptions = $schemaTool->getSchemaFromMetadata([$em->getClassMetadata(Card::class)]) + $platformOptions = $schemaTool->getSchemaFromMetadata([$em->getClassMetadata(Card::class)]) ->getTable('Card') ->getColumn('suit') - ->getCustomSchemaOptions(); + ->getPlatformOptions(); - self::assertArrayHasKey('enumType', $customSchemaOptions); - self::assertSame(Suit::class, $customSchemaOptions['enumType']); + self::assertArrayHasKey('enumType', $platformOptions); + self::assertSame(Suit::class, $platformOptions['enumType']); } /** @group DDC-3671 */ From ddc7d953b99bee939aecdb326321e2a642e1dc64 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 26 Jul 2023 12:37:42 +0200 Subject: [PATCH 65/72] Avoid self deprecation --- lib/Doctrine/ORM/Configuration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Doctrine/ORM/Configuration.php b/lib/Doctrine/ORM/Configuration.php index a8ad0d2712d..fa5f6d15e9a 100644 --- a/lib/Doctrine/ORM/Configuration.php +++ b/lib/Doctrine/ORM/Configuration.php @@ -333,7 +333,7 @@ public function setResultCache(CacheItemPoolInterface $cache): void */ public function getQueryCacheImpl() { - Deprecation::trigger( + Deprecation::triggerIfCalledFromOutside( 'doctrine/orm', 'https://github.com/doctrine/orm/pull/9002', 'Method %s() is deprecated and will be removed in Doctrine ORM 3.0. Use getQueryCache() instead.', From a5161e9485338966ab6060adf22188f441c0e563 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 26 Jul 2023 15:57:37 +0200 Subject: [PATCH 66/72] Other solution --- lib/Doctrine/ORM/Configuration.php | 2 +- .../Command/ClearCache/QueryCommand.php | 40 +++++++++++-------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/lib/Doctrine/ORM/Configuration.php b/lib/Doctrine/ORM/Configuration.php index fa5f6d15e9a..a8ad0d2712d 100644 --- a/lib/Doctrine/ORM/Configuration.php +++ b/lib/Doctrine/ORM/Configuration.php @@ -333,7 +333,7 @@ public function setResultCache(CacheItemPoolInterface $cache): void */ public function getQueryCacheImpl() { - Deprecation::triggerIfCalledFromOutside( + Deprecation::trigger( 'doctrine/orm', 'https://github.com/doctrine/orm/pull/9002', 'Method %s() is deprecated and will be removed in Doctrine ORM 3.0. Use getQueryCache() instead.', diff --git a/lib/Doctrine/ORM/Tools/Console/Command/ClearCache/QueryCommand.php b/lib/Doctrine/ORM/Tools/Console/Command/ClearCache/QueryCommand.php index 2b2910410da..7be330d303d 100644 --- a/lib/Doctrine/ORM/Tools/Console/Command/ClearCache/QueryCommand.php +++ b/lib/Doctrine/ORM/Tools/Console/Command/ClearCache/QueryCommand.php @@ -63,27 +63,35 @@ protected function execute(InputInterface $input, OutputInterface $output) { $ui = (new SymfonyStyle($input, $output))->getErrorStyle(); - $em = $this->getEntityManager($input); - $cache = $em->getConfiguration()->getQueryCache(); - $cacheDriver = $em->getConfiguration()->getQueryCacheImpl(); + $em = $this->getEntityManager($input); + $cache = $em->getConfiguration()->getQueryCache(); - if (! $cacheDriver) { - throw new InvalidArgumentException('No Query cache driver is configured on given EntityManager.'); - } - - if ($cacheDriver instanceof ApcCache || $cache instanceof ApcuAdapter) { + if ($cache instanceof ApcuAdapter) { throw new LogicException('Cannot clear APCu Cache from Console, it\'s shared in the Webserver memory and not accessible from the CLI.'); } - if ($cacheDriver instanceof XcacheCache) { - throw new LogicException('Cannot clear XCache Cache from Console, it\'s shared in the Webserver memory and not accessible from the CLI.'); - } + $cacheDriver = null; + if (! $cache) { + $cacheDriver = $em->getConfiguration()->getQueryCacheImpl(); + + if (! $cacheDriver) { + throw new InvalidArgumentException('No Query cache driver is configured on given EntityManager.'); + } + + if ($cacheDriver instanceof ApcCache) { + throw new LogicException('Cannot clear APCu Cache from Console, it\'s shared in the Webserver memory and not accessible from the CLI.'); + } - if (! ($cacheDriver instanceof ClearableCache)) { - throw new LogicException(sprintf( - 'Can only clear cache when ClearableCache interface is implemented, %s does not implement.', - get_debug_type($cacheDriver) - )); + if ($cacheDriver instanceof XcacheCache) { + throw new LogicException('Cannot clear XCache Cache from Console, it\'s shared in the Webserver memory and not accessible from the CLI.'); + } + + if (! ($cacheDriver instanceof ClearableCache)) { + throw new LogicException(sprintf( + 'Can only clear cache when ClearableCache interface is implemented, %s does not implement.', + get_debug_type($cacheDriver) + )); + } } $ui->comment('Clearing all Query cache entries'); From 442f073d25e9d9c0563ad654894cbf52287c8b7a Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 26 Jul 2023 16:34:00 +0200 Subject: [PATCH 67/72] Fix static analysis --- .../Tools/Console/Command/ClearCache/QueryCommand.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/Doctrine/ORM/Tools/Console/Command/ClearCache/QueryCommand.php b/lib/Doctrine/ORM/Tools/Console/Command/ClearCache/QueryCommand.php index 7be330d303d..3ce2fb6cf1b 100644 --- a/lib/Doctrine/ORM/Tools/Console/Command/ClearCache/QueryCommand.php +++ b/lib/Doctrine/ORM/Tools/Console/Command/ClearCache/QueryCommand.php @@ -17,6 +17,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use function assert; use function get_debug_type; use function sprintf; @@ -96,7 +97,13 @@ protected function execute(InputInterface $input, OutputInterface $output) $ui->comment('Clearing all Query cache entries'); - $result = $cache ? $cache->clear() : $cacheDriver->deleteAll(); + if ($cache) { + $result = $cache->clear(); + } else { + assert($cacheDriver !== null); + $result = $cacheDriver->deleteAll(); + } + $message = $result ? 'Successfully deleted cache entries.' : 'No cache entries were deleted.'; if ($input->getOption('flush') === true && ! $cache) { From 8e20e1598e9656879ff9dc9fe9657e85f6404d02 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 28 Jul 2023 16:05:51 +0200 Subject: [PATCH 68/72] Introduce FilterCollection#restore method (#10537) * Introduce FilterCollection#restore method * Add suspend method instead * Add more tests --- docs/en/reference/filters.rst | 31 ++++++ lib/Doctrine/ORM/Query/FilterCollection.php | 82 ++++++++++++++++ .../Tests/ORM/Query/FilterCollectionTest.php | 96 ++++++++++++++++++- 3 files changed, 206 insertions(+), 3 deletions(-) diff --git a/docs/en/reference/filters.rst b/docs/en/reference/filters.rst index bf4e733a7f6..58cabbc48fd 100644 --- a/docs/en/reference/filters.rst +++ b/docs/en/reference/filters.rst @@ -93,3 +93,34 @@ object. want to refresh or reload an object after having modified a filter or the FilterCollection, then you should clear the EntityManager and re-fetch your entities, having the new rules for filtering applied. + + +Suspending/Restoring Filters +---------------------------- +When a filter is disabled, the instance is fully deleted and all the filter +parameters previously set are lost. Then, if you enable it again, a new filter +is created without the previous filter parameters. If you want to keep a filter +(in order to use it later) but temporary disable it, you'll need to use the +``FilterCollection#suspend($name)`` and ``FilterCollection#restore($name)`` +methods instead. + +.. code-block:: php + + getFilters()->enable("locale"); + $filter->setParameter('locale', 'en'); + + // Temporary suspend the filter + $filter = $em->getFilters()->suspend("locale"); + + // Do things + + // Then restore it, the locale parameter will still be set + $filter = $em->getFilters()->restore("locale"); + +.. warning:: + If you enable a previously disabled filter, doctrine will create a new + one without keeping any of the previously parameter set with + ``SQLFilter#setParameter()`` or ``SQLFilter#getParameterList()``. If you + want to restore the previously disabled filter instead, you must use the + ``FilterCollection#restore($name)`` method. diff --git a/lib/Doctrine/ORM/Query/FilterCollection.php b/lib/Doctrine/ORM/Query/FilterCollection.php index 01c7e2c61c1..d8421baac6a 100644 --- a/lib/Doctrine/ORM/Query/FilterCollection.php +++ b/lib/Doctrine/ORM/Query/FilterCollection.php @@ -51,6 +51,14 @@ class FilterCollection */ private $enabledFilters = []; + /** + * Instances of suspended filters. + * + * @var SQLFilter[] + * @psalm-var array + */ + private $suspendedFilters = []; + /** * The filter hash from the last time the query was parsed. * @@ -83,6 +91,17 @@ public function getEnabledFilters() return $this->enabledFilters; } + /** + * Gets all the suspended filters. + * + * @return SQLFilter[] The suspended filters. + * @psalm-return array + */ + public function getSuspendedFilters(): array + { + return $this->suspendedFilters; + } + /** * Enables a filter from the collection. * @@ -105,6 +124,9 @@ public function enable($name) $this->enabledFilters[$name] = new $filterClass($this->em); + // In case a suspended filter with the same name was forgotten + unset($this->suspendedFilters[$name]); + // Keep the enabled filters sorted for the hash ksort($this->enabledFilters); @@ -135,6 +157,54 @@ public function disable($name) return $filter; } + /** + * Suspend a filter. + * + * @param string $name Name of the filter. + * + * @return SQLFilter The suspended filter. + * + * @throws InvalidArgumentException If the filter does not exist. + */ + public function suspend(string $name): SQLFilter + { + // Get the filter to return it + $filter = $this->getFilter($name); + + $this->suspendedFilters[$name] = $filter; + unset($this->enabledFilters[$name]); + + $this->setFiltersStateDirty(); + + return $filter; + } + + /** + * Restore a disabled filter from the collection. + * + * @param string $name Name of the filter. + * + * @return SQLFilter The restored filter. + * + * @throws InvalidArgumentException If the filter does not exist. + */ + public function restore(string $name): SQLFilter + { + if (! $this->isSuspended($name)) { + throw new InvalidArgumentException("Filter '" . $name . "' is not suspended."); + } + + $this->enabledFilters[$name] = $this->suspendedFilters[$name]; + unset($this->suspendedFilters[$name]); + + // Keep the enabled filters sorted for the hash + ksort($this->enabledFilters); + + $this->setFiltersStateDirty(); + + return $this->enabledFilters[$name]; + } + /** * Gets an enabled filter from the collection. * @@ -177,6 +247,18 @@ public function isEnabled($name) return isset($this->enabledFilters[$name]); } + /** + * Checks if a filter is suspended. + * + * @param string $name Name of the filter. + * + * @return bool True if the filter is suspended, false otherwise. + */ + public function isSuspended(string $name): bool + { + return isset($this->suspendedFilters[$name]); + } + /** * Checks if the filter collection is clean. * diff --git a/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php b/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php index c750dc74ce5..a4c0f998b47 100644 --- a/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php +++ b/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php @@ -30,15 +30,54 @@ public function testEnable(): void self::assertCount(0, $filterCollection->getEnabledFilters()); - $filterCollection->enable('testFilter'); + $filter1 = $filterCollection->enable('testFilter'); $enabledFilters = $filterCollection->getEnabledFilters(); self::assertCount(1, $enabledFilters); self::assertContainsOnly(MyFilter::class, $enabledFilters); - $filterCollection->disable('testFilter'); + $filter2 = $filterCollection->disable('testFilter'); self::assertCount(0, $filterCollection->getEnabledFilters()); + self::assertSame($filter1, $filter2); + + $filter3 = $filterCollection->enable('testFilter'); + self::assertNotSame($filter1, $filter3); + + $filter4 = $filterCollection->suspend('testFilter'); + self::assertSame($filter3, $filter4); + + $filter5 = $filterCollection->enable('testFilter'); + self::assertNotSame($filter4, $filter5); + + self::assertCount(1, $enabledFilters); + self::assertContainsOnly(MyFilter::class, $enabledFilters); + } + + public function testSuspend(): void + { + $filterCollection = $this->em->getFilters(); + + self::assertCount(0, $filterCollection->getEnabledFilters()); + + $filter1 = $filterCollection->enable('testFilter'); + self::assertCount(1, $filterCollection->getEnabledFilters()); + + $filter2 = $filterCollection->suspend('testFilter'); + self::assertSame($filter1, $filter2); + self::assertCount(0, $filterCollection->getEnabledFilters()); + + $filter3 = $filterCollection->restore('testFilter'); + self::assertSame($filter1, $filter3); + self::assertCount(1, $filterCollection->getEnabledFilters()); + } + + public function testRestoreFailure(): void + { + $filterCollection = $this->em->getFilters(); + + $this->expectException(InvalidArgumentException::class); + $filterCollection->suspend('testFilter'); } public function testHasFilter(): void @@ -49,7 +88,6 @@ public function testHasFilter(): void self::assertFalse($filterCollection->has('fakeFilter')); } - /** @depends testEnable */ public function testIsEnabled(): void { $filterCollection = $this->em->getFilters(); @@ -59,6 +97,41 @@ public function testIsEnabled(): void $filterCollection->enable('testFilter'); self::assertTrue($filterCollection->isEnabled('testFilter')); + + $filterCollection->suspend('testFilter'); + + self::assertFalse($filterCollection->isEnabled('testFilter')); + + $filterCollection->restore('testFilter'); + + self::assertTrue($filterCollection->isEnabled('testFilter')); + + self::assertFalse($filterCollection->isEnabled('wrongFilter')); + } + + public function testIsSuspended(): void + { + $filterCollection = $this->em->getFilters(); + + self::assertFalse($filterCollection->isSuspended('testFilter')); + + $filterCollection->enable('testFilter'); + + self::assertFalse($filterCollection->isSuspended('testFilter')); + + $filterCollection->suspend('testFilter'); + + self::assertTrue($filterCollection->isSuspended('testFilter')); + + $filterCollection->restore('testFilter'); + + self::assertFalse($filterCollection->isSuspended('testFilter')); + + $filterCollection->disable('testFilter'); + + self::assertFalse($filterCollection->isSuspended('testFilter')); + + self::assertFalse($filterCollection->isSuspended('wrongFilter')); } public function testGetFilterInvalidArgument(): void @@ -74,6 +147,11 @@ public function testGetFilter(): void $filterCollection->enable('testFilter'); self::assertInstanceOf(MyFilter::class, $filterCollection->getFilter('testFilter')); + + $filterCollection->suspend('testFilter'); + + $this->expectException(InvalidArgumentException::class); + $filterCollection->getFilter('testFilter'); } public function testHashing(): void @@ -99,6 +177,18 @@ public function testHashing(): void self::assertTrue($filterCollection->isClean()); self::assertSame($hash, $filterCollection->getHash()); + $filterCollection->suspend('testFilter'); + + self::assertFalse($filterCollection->isClean()); + self::assertSame($oldHash, $filterCollection->getHash()); + self::assertTrue($filterCollection->isClean()); + + $filterCollection->restore('testFilter'); + + self::assertFalse($filterCollection->isClean()); + self::assertSame($hash, $filterCollection->getHash()); + self::assertTrue($filterCollection->isClean()); + $filterCollection->disable('testFilter'); self::assertFalse($filterCollection->isClean()); From 64ee76e94ea9a414136a9cd1f1e9bc577da4bf71 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 28 Jul 2023 16:05:51 +0200 Subject: [PATCH 69/72] Introduce FilterCollection#restore method (#10537) * Introduce FilterCollection#restore method * Add suspend method instead * Add more tests --- docs/en/reference/filters.rst | 31 ++++++ lib/Doctrine/ORM/Query/FilterCollection.php | 82 ++++++++++++++++ .../Tests/ORM/Query/FilterCollectionTest.php | 96 ++++++++++++++++++- 3 files changed, 206 insertions(+), 3 deletions(-) diff --git a/docs/en/reference/filters.rst b/docs/en/reference/filters.rst index bf4e733a7f6..58cabbc48fd 100644 --- a/docs/en/reference/filters.rst +++ b/docs/en/reference/filters.rst @@ -93,3 +93,34 @@ object. want to refresh or reload an object after having modified a filter or the FilterCollection, then you should clear the EntityManager and re-fetch your entities, having the new rules for filtering applied. + + +Suspending/Restoring Filters +---------------------------- +When a filter is disabled, the instance is fully deleted and all the filter +parameters previously set are lost. Then, if you enable it again, a new filter +is created without the previous filter parameters. If you want to keep a filter +(in order to use it later) but temporary disable it, you'll need to use the +``FilterCollection#suspend($name)`` and ``FilterCollection#restore($name)`` +methods instead. + +.. code-block:: php + + getFilters()->enable("locale"); + $filter->setParameter('locale', 'en'); + + // Temporary suspend the filter + $filter = $em->getFilters()->suspend("locale"); + + // Do things + + // Then restore it, the locale parameter will still be set + $filter = $em->getFilters()->restore("locale"); + +.. warning:: + If you enable a previously disabled filter, doctrine will create a new + one without keeping any of the previously parameter set with + ``SQLFilter#setParameter()`` or ``SQLFilter#getParameterList()``. If you + want to restore the previously disabled filter instead, you must use the + ``FilterCollection#restore($name)`` method. diff --git a/lib/Doctrine/ORM/Query/FilterCollection.php b/lib/Doctrine/ORM/Query/FilterCollection.php index 01c7e2c61c1..d8421baac6a 100644 --- a/lib/Doctrine/ORM/Query/FilterCollection.php +++ b/lib/Doctrine/ORM/Query/FilterCollection.php @@ -51,6 +51,14 @@ class FilterCollection */ private $enabledFilters = []; + /** + * Instances of suspended filters. + * + * @var SQLFilter[] + * @psalm-var array + */ + private $suspendedFilters = []; + /** * The filter hash from the last time the query was parsed. * @@ -83,6 +91,17 @@ public function getEnabledFilters() return $this->enabledFilters; } + /** + * Gets all the suspended filters. + * + * @return SQLFilter[] The suspended filters. + * @psalm-return array + */ + public function getSuspendedFilters(): array + { + return $this->suspendedFilters; + } + /** * Enables a filter from the collection. * @@ -105,6 +124,9 @@ public function enable($name) $this->enabledFilters[$name] = new $filterClass($this->em); + // In case a suspended filter with the same name was forgotten + unset($this->suspendedFilters[$name]); + // Keep the enabled filters sorted for the hash ksort($this->enabledFilters); @@ -135,6 +157,54 @@ public function disable($name) return $filter; } + /** + * Suspend a filter. + * + * @param string $name Name of the filter. + * + * @return SQLFilter The suspended filter. + * + * @throws InvalidArgumentException If the filter does not exist. + */ + public function suspend(string $name): SQLFilter + { + // Get the filter to return it + $filter = $this->getFilter($name); + + $this->suspendedFilters[$name] = $filter; + unset($this->enabledFilters[$name]); + + $this->setFiltersStateDirty(); + + return $filter; + } + + /** + * Restore a disabled filter from the collection. + * + * @param string $name Name of the filter. + * + * @return SQLFilter The restored filter. + * + * @throws InvalidArgumentException If the filter does not exist. + */ + public function restore(string $name): SQLFilter + { + if (! $this->isSuspended($name)) { + throw new InvalidArgumentException("Filter '" . $name . "' is not suspended."); + } + + $this->enabledFilters[$name] = $this->suspendedFilters[$name]; + unset($this->suspendedFilters[$name]); + + // Keep the enabled filters sorted for the hash + ksort($this->enabledFilters); + + $this->setFiltersStateDirty(); + + return $this->enabledFilters[$name]; + } + /** * Gets an enabled filter from the collection. * @@ -177,6 +247,18 @@ public function isEnabled($name) return isset($this->enabledFilters[$name]); } + /** + * Checks if a filter is suspended. + * + * @param string $name Name of the filter. + * + * @return bool True if the filter is suspended, false otherwise. + */ + public function isSuspended(string $name): bool + { + return isset($this->suspendedFilters[$name]); + } + /** * Checks if the filter collection is clean. * diff --git a/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php b/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php index c750dc74ce5..a4c0f998b47 100644 --- a/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php +++ b/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php @@ -30,15 +30,54 @@ public function testEnable(): void self::assertCount(0, $filterCollection->getEnabledFilters()); - $filterCollection->enable('testFilter'); + $filter1 = $filterCollection->enable('testFilter'); $enabledFilters = $filterCollection->getEnabledFilters(); self::assertCount(1, $enabledFilters); self::assertContainsOnly(MyFilter::class, $enabledFilters); - $filterCollection->disable('testFilter'); + $filter2 = $filterCollection->disable('testFilter'); self::assertCount(0, $filterCollection->getEnabledFilters()); + self::assertSame($filter1, $filter2); + + $filter3 = $filterCollection->enable('testFilter'); + self::assertNotSame($filter1, $filter3); + + $filter4 = $filterCollection->suspend('testFilter'); + self::assertSame($filter3, $filter4); + + $filter5 = $filterCollection->enable('testFilter'); + self::assertNotSame($filter4, $filter5); + + self::assertCount(1, $enabledFilters); + self::assertContainsOnly(MyFilter::class, $enabledFilters); + } + + public function testSuspend(): void + { + $filterCollection = $this->em->getFilters(); + + self::assertCount(0, $filterCollection->getEnabledFilters()); + + $filter1 = $filterCollection->enable('testFilter'); + self::assertCount(1, $filterCollection->getEnabledFilters()); + + $filter2 = $filterCollection->suspend('testFilter'); + self::assertSame($filter1, $filter2); + self::assertCount(0, $filterCollection->getEnabledFilters()); + + $filter3 = $filterCollection->restore('testFilter'); + self::assertSame($filter1, $filter3); + self::assertCount(1, $filterCollection->getEnabledFilters()); + } + + public function testRestoreFailure(): void + { + $filterCollection = $this->em->getFilters(); + + $this->expectException(InvalidArgumentException::class); + $filterCollection->suspend('testFilter'); } public function testHasFilter(): void @@ -49,7 +88,6 @@ public function testHasFilter(): void self::assertFalse($filterCollection->has('fakeFilter')); } - /** @depends testEnable */ public function testIsEnabled(): void { $filterCollection = $this->em->getFilters(); @@ -59,6 +97,41 @@ public function testIsEnabled(): void $filterCollection->enable('testFilter'); self::assertTrue($filterCollection->isEnabled('testFilter')); + + $filterCollection->suspend('testFilter'); + + self::assertFalse($filterCollection->isEnabled('testFilter')); + + $filterCollection->restore('testFilter'); + + self::assertTrue($filterCollection->isEnabled('testFilter')); + + self::assertFalse($filterCollection->isEnabled('wrongFilter')); + } + + public function testIsSuspended(): void + { + $filterCollection = $this->em->getFilters(); + + self::assertFalse($filterCollection->isSuspended('testFilter')); + + $filterCollection->enable('testFilter'); + + self::assertFalse($filterCollection->isSuspended('testFilter')); + + $filterCollection->suspend('testFilter'); + + self::assertTrue($filterCollection->isSuspended('testFilter')); + + $filterCollection->restore('testFilter'); + + self::assertFalse($filterCollection->isSuspended('testFilter')); + + $filterCollection->disable('testFilter'); + + self::assertFalse($filterCollection->isSuspended('testFilter')); + + self::assertFalse($filterCollection->isSuspended('wrongFilter')); } public function testGetFilterInvalidArgument(): void @@ -74,6 +147,11 @@ public function testGetFilter(): void $filterCollection->enable('testFilter'); self::assertInstanceOf(MyFilter::class, $filterCollection->getFilter('testFilter')); + + $filterCollection->suspend('testFilter'); + + $this->expectException(InvalidArgumentException::class); + $filterCollection->getFilter('testFilter'); } public function testHashing(): void @@ -99,6 +177,18 @@ public function testHashing(): void self::assertTrue($filterCollection->isClean()); self::assertSame($hash, $filterCollection->getHash()); + $filterCollection->suspend('testFilter'); + + self::assertFalse($filterCollection->isClean()); + self::assertSame($oldHash, $filterCollection->getHash()); + self::assertTrue($filterCollection->isClean()); + + $filterCollection->restore('testFilter'); + + self::assertFalse($filterCollection->isClean()); + self::assertSame($hash, $filterCollection->getHash()); + self::assertTrue($filterCollection->isClean()); + $filterCollection->disable('testFilter'); self::assertFalse($filterCollection->isClean()); From d2de4ec03c63ddd7bdfda0421e237e11343c75ee Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Fri, 28 Jul 2023 16:08:17 +0200 Subject: [PATCH 70/72] Revert "Introduce FilterCollection#restore method (#10537)" This reverts commit 8e20e1598e9656879ff9dc9fe9657e85f6404d02. --- docs/en/reference/filters.rst | 31 ------ lib/Doctrine/ORM/Query/FilterCollection.php | 82 ---------------- .../Tests/ORM/Query/FilterCollectionTest.php | 96 +------------------ 3 files changed, 3 insertions(+), 206 deletions(-) diff --git a/docs/en/reference/filters.rst b/docs/en/reference/filters.rst index 58cabbc48fd..bf4e733a7f6 100644 --- a/docs/en/reference/filters.rst +++ b/docs/en/reference/filters.rst @@ -93,34 +93,3 @@ object. want to refresh or reload an object after having modified a filter or the FilterCollection, then you should clear the EntityManager and re-fetch your entities, having the new rules for filtering applied. - - -Suspending/Restoring Filters ----------------------------- -When a filter is disabled, the instance is fully deleted and all the filter -parameters previously set are lost. Then, if you enable it again, a new filter -is created without the previous filter parameters. If you want to keep a filter -(in order to use it later) but temporary disable it, you'll need to use the -``FilterCollection#suspend($name)`` and ``FilterCollection#restore($name)`` -methods instead. - -.. code-block:: php - - getFilters()->enable("locale"); - $filter->setParameter('locale', 'en'); - - // Temporary suspend the filter - $filter = $em->getFilters()->suspend("locale"); - - // Do things - - // Then restore it, the locale parameter will still be set - $filter = $em->getFilters()->restore("locale"); - -.. warning:: - If you enable a previously disabled filter, doctrine will create a new - one without keeping any of the previously parameter set with - ``SQLFilter#setParameter()`` or ``SQLFilter#getParameterList()``. If you - want to restore the previously disabled filter instead, you must use the - ``FilterCollection#restore($name)`` method. diff --git a/lib/Doctrine/ORM/Query/FilterCollection.php b/lib/Doctrine/ORM/Query/FilterCollection.php index d8421baac6a..01c7e2c61c1 100644 --- a/lib/Doctrine/ORM/Query/FilterCollection.php +++ b/lib/Doctrine/ORM/Query/FilterCollection.php @@ -51,14 +51,6 @@ class FilterCollection */ private $enabledFilters = []; - /** - * Instances of suspended filters. - * - * @var SQLFilter[] - * @psalm-var array - */ - private $suspendedFilters = []; - /** * The filter hash from the last time the query was parsed. * @@ -91,17 +83,6 @@ public function getEnabledFilters() return $this->enabledFilters; } - /** - * Gets all the suspended filters. - * - * @return SQLFilter[] The suspended filters. - * @psalm-return array - */ - public function getSuspendedFilters(): array - { - return $this->suspendedFilters; - } - /** * Enables a filter from the collection. * @@ -124,9 +105,6 @@ public function enable($name) $this->enabledFilters[$name] = new $filterClass($this->em); - // In case a suspended filter with the same name was forgotten - unset($this->suspendedFilters[$name]); - // Keep the enabled filters sorted for the hash ksort($this->enabledFilters); @@ -157,54 +135,6 @@ public function disable($name) return $filter; } - /** - * Suspend a filter. - * - * @param string $name Name of the filter. - * - * @return SQLFilter The suspended filter. - * - * @throws InvalidArgumentException If the filter does not exist. - */ - public function suspend(string $name): SQLFilter - { - // Get the filter to return it - $filter = $this->getFilter($name); - - $this->suspendedFilters[$name] = $filter; - unset($this->enabledFilters[$name]); - - $this->setFiltersStateDirty(); - - return $filter; - } - - /** - * Restore a disabled filter from the collection. - * - * @param string $name Name of the filter. - * - * @return SQLFilter The restored filter. - * - * @throws InvalidArgumentException If the filter does not exist. - */ - public function restore(string $name): SQLFilter - { - if (! $this->isSuspended($name)) { - throw new InvalidArgumentException("Filter '" . $name . "' is not suspended."); - } - - $this->enabledFilters[$name] = $this->suspendedFilters[$name]; - unset($this->suspendedFilters[$name]); - - // Keep the enabled filters sorted for the hash - ksort($this->enabledFilters); - - $this->setFiltersStateDirty(); - - return $this->enabledFilters[$name]; - } - /** * Gets an enabled filter from the collection. * @@ -247,18 +177,6 @@ public function isEnabled($name) return isset($this->enabledFilters[$name]); } - /** - * Checks if a filter is suspended. - * - * @param string $name Name of the filter. - * - * @return bool True if the filter is suspended, false otherwise. - */ - public function isSuspended(string $name): bool - { - return isset($this->suspendedFilters[$name]); - } - /** * Checks if the filter collection is clean. * diff --git a/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php b/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php index a4c0f998b47..c750dc74ce5 100644 --- a/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php +++ b/tests/Doctrine/Tests/ORM/Query/FilterCollectionTest.php @@ -30,54 +30,15 @@ public function testEnable(): void self::assertCount(0, $filterCollection->getEnabledFilters()); - $filter1 = $filterCollection->enable('testFilter'); + $filterCollection->enable('testFilter'); $enabledFilters = $filterCollection->getEnabledFilters(); self::assertCount(1, $enabledFilters); self::assertContainsOnly(MyFilter::class, $enabledFilters); - $filter2 = $filterCollection->disable('testFilter'); - self::assertCount(0, $filterCollection->getEnabledFilters()); - self::assertSame($filter1, $filter2); - - $filter3 = $filterCollection->enable('testFilter'); - self::assertNotSame($filter1, $filter3); - - $filter4 = $filterCollection->suspend('testFilter'); - self::assertSame($filter3, $filter4); - - $filter5 = $filterCollection->enable('testFilter'); - self::assertNotSame($filter4, $filter5); - - self::assertCount(1, $enabledFilters); - self::assertContainsOnly(MyFilter::class, $enabledFilters); - } - - public function testSuspend(): void - { - $filterCollection = $this->em->getFilters(); - - self::assertCount(0, $filterCollection->getEnabledFilters()); - - $filter1 = $filterCollection->enable('testFilter'); - self::assertCount(1, $filterCollection->getEnabledFilters()); - - $filter2 = $filterCollection->suspend('testFilter'); - self::assertSame($filter1, $filter2); + $filterCollection->disable('testFilter'); self::assertCount(0, $filterCollection->getEnabledFilters()); - - $filter3 = $filterCollection->restore('testFilter'); - self::assertSame($filter1, $filter3); - self::assertCount(1, $filterCollection->getEnabledFilters()); - } - - public function testRestoreFailure(): void - { - $filterCollection = $this->em->getFilters(); - - $this->expectException(InvalidArgumentException::class); - $filterCollection->suspend('testFilter'); } public function testHasFilter(): void @@ -88,6 +49,7 @@ public function testHasFilter(): void self::assertFalse($filterCollection->has('fakeFilter')); } + /** @depends testEnable */ public function testIsEnabled(): void { $filterCollection = $this->em->getFilters(); @@ -97,41 +59,6 @@ public function testIsEnabled(): void $filterCollection->enable('testFilter'); self::assertTrue($filterCollection->isEnabled('testFilter')); - - $filterCollection->suspend('testFilter'); - - self::assertFalse($filterCollection->isEnabled('testFilter')); - - $filterCollection->restore('testFilter'); - - self::assertTrue($filterCollection->isEnabled('testFilter')); - - self::assertFalse($filterCollection->isEnabled('wrongFilter')); - } - - public function testIsSuspended(): void - { - $filterCollection = $this->em->getFilters(); - - self::assertFalse($filterCollection->isSuspended('testFilter')); - - $filterCollection->enable('testFilter'); - - self::assertFalse($filterCollection->isSuspended('testFilter')); - - $filterCollection->suspend('testFilter'); - - self::assertTrue($filterCollection->isSuspended('testFilter')); - - $filterCollection->restore('testFilter'); - - self::assertFalse($filterCollection->isSuspended('testFilter')); - - $filterCollection->disable('testFilter'); - - self::assertFalse($filterCollection->isSuspended('testFilter')); - - self::assertFalse($filterCollection->isSuspended('wrongFilter')); } public function testGetFilterInvalidArgument(): void @@ -147,11 +74,6 @@ public function testGetFilter(): void $filterCollection->enable('testFilter'); self::assertInstanceOf(MyFilter::class, $filterCollection->getFilter('testFilter')); - - $filterCollection->suspend('testFilter'); - - $this->expectException(InvalidArgumentException::class); - $filterCollection->getFilter('testFilter'); } public function testHashing(): void @@ -177,18 +99,6 @@ public function testHashing(): void self::assertTrue($filterCollection->isClean()); self::assertSame($hash, $filterCollection->getHash()); - $filterCollection->suspend('testFilter'); - - self::assertFalse($filterCollection->isClean()); - self::assertSame($oldHash, $filterCollection->getHash()); - self::assertTrue($filterCollection->isClean()); - - $filterCollection->restore('testFilter'); - - self::assertFalse($filterCollection->isClean()); - self::assertSame($hash, $filterCollection->getHash()); - self::assertTrue($filterCollection->isClean()); - $filterCollection->disable('testFilter'); self::assertFalse($filterCollection->isClean()); From f01d107edc5bebbd3d628a7c6cb1934339aea435 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Tue, 1 Aug 2023 08:40:47 +0200 Subject: [PATCH 71/72] Fix commit order computation for self-referencing entities with application-provided IDs This excludes such associations from the commit order computation, since the foreign key constraint will be satisfied when inserting the row. See https://github.com/doctrine/orm/pull/10735/ for more details about this edge case. --- lib/Doctrine/ORM/UnitOfWork.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index 821d73a7a34..f5bde69cf5a 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -1315,6 +1315,14 @@ private function computeInsertExecutionOrder(): array continue; } + // An entity that references back to itself _and_ uses an application-provided ID + // (the "NONE" generator strategy) can be exempted from commit order computation. + // See https://github.com/doctrine/orm/pull/10735/ for more details on this edge case. + // A non-NULLable self-reference would be a cycle in the graph. + if ($targetEntity === $entity && $class->isIdentifierNatural()) { + continue; + } + // According to https://www.doctrine-project.org/projects/doctrine-orm/en/2.14/reference/annotations-reference.html#annref_joincolumn, // the default for "nullable" is true. Unfortunately, it seems this default is not applied at the metadata driver, factory or other // level, but in fact we may have an undefined 'nullable' key here, so we must assume that default here as well. From 0d3ce5d4f8da5b3f23db801713a0f18a1125270d Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Tue, 1 Aug 2023 08:53:56 +0200 Subject: [PATCH 72/72] Exclude deprecated classes from Psalm checks (until 3.0) --- psalm.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/psalm.xml b/psalm.xml index 262a6ed0aa6..9cb91ee7714 100644 --- a/psalm.xml +++ b/psalm.xml @@ -45,6 +45,10 @@ + + + +