Compare commits

...

15 Commits

Author SHA1 Message Date
Grégoire Paris
398ab0547a Merge pull request #11162 from greg0ire/fix-libxml-compat
Remove redundant tags
2024-01-16 22:32:04 +01:00
Grégoire Paris
8f15337b03 Remove redundant tags
The "any" tags inside the definition for mapped superclasses and
embeddables duplicate what is already done for entities.

The other removed "any" tags are also redundant, as they duplicate
what's already done inside the grandparent "choice" tag.

Starting with version libxml 2.12, such redundant tags cause errors
about the content model not being "determinist".

Fixes #11117
2024-01-16 22:01:16 +01:00
Matthias Pigulla
a8632aca8f Keep the declared mapping information when using attribute overrides (#11135)
When using `AttributeOverride` to override mapping information inherited from a parent class (a mapped superclass), make sure to keep information about where the field was originally declared.

This is important for `private` fields: Without the correct `declared` information, it will lead to errors when cached mapping information is loaded, reflection wakes up and looks for the private field in the wrong class.
2024-01-13 00:06:34 +01:00
Matthias Pigulla
3dd3d38857 Fix @SequenceGeneratorDefinition inheritance, take 1 (#11050)
#10927 reported that #10455 broke the way how the default `@SequenceGeneratorDefinition` is created and inherited by subclasses for ID columns using `@GeneratedValue(strategy="SEQUENCE")`.

First, I had to understand how `@SequenceGeneratorDefinition` has been handled before #10455 when entity inheritance comes into play:

* Entity and mapped superclasses inherit the ID generator type (as given by `@GeneratedValue`) from their parent classes
* `@SequenceGeneratorDefinition`, however, is not generally inherited
* ... instead, a default sequence generator definition is created for every class when no explicit configuration is given. In this case, sequence names are based on the current class' table name.
* Once a root entity has been identified, all subclasses inherit its sequence generator definition unchanged.

#### Why did #10455 break this?

When I implemented #10455, I was mislead by two tests `BasicInheritanceMappingTest::testGeneratedValueFromMappedSuperclass` and `BasicInheritanceMappingTest::testMultipleMappedSuperclasses`.

These tests check the sequence generator definition that is inherited by an entity class from a mapped superclass, either directly or through an additional (intermediate) mapped superclass.

The tests expect the sequence generator definition on the entity _to be the same_ as on the base mapped superclass.

The reason why the tests worked before was the quirky behaviour of the annotation and attribute drivers that #10455 was aiming at: The drivers did not report the `@SequenceGeneratorDefinition` on the base mapped superclass where it was actually defined. Instead, they reported this `@SequenceGeneratorDefinition` for the entity class only.

This means the inheritance rules stated above did not take effect, since the ID field with the sequence generator was virtually pushed down to the entity class.

In #10455, I did not realize that these failing tests had to do with the quirky and changed mapping driver behaviour. Instead, I tried to "fix" the inheritance rules by passing along the sequence generator definition unchanged once the ID column had been defined.

#### Consequences of the change suggested here

This PR reverts the changes made to `@SequenceGeneratorDefinition` inheritance behaviour that were done in #10455.

This means that with the new "report fields where declared" driver mode (which is active in our functional tests) we can not expect the sequence generator definition to be inherited from mapped superclasses. The two test cases from `BasicInheritanceMappingTest` are removed.

I will leave a notice in #10455 to indicate that the new driver mode also affects sequence generator definitions.

The `GH10927Test` test case validates the sequence names generated in a few cases. In fact, I wrote this test against the `2.15.x` branch to make sure we get results that are consistent with the previous behaviour.

This also means `@SequenceGeneratorDefinition` on mapped superclasses is pointless: The mapped superclass does not make use of the definition itself (it has no table), and the setting is never inherited to child classes.
 
Fixes #10927. There is another implementation with slightly different inheritance semantics in #11052, in case the fix is not good enough and we'd need to review the topic later on.
2024-01-12 22:59:14 +01:00
Matthias Pigulla
c6b3509aa9 Include ON DELETE CASCADE associations in the delete order computation (#10913)
In order to resolve #10348, some changes were included in #10547 to improve the computed _delete_ order for entities. 

One assumption was that foreign key references with `ON DELETE SET NULL` or `... CASCADE` need not need to be taken into consideration when planning the deletion order, since the RDBMS would unset or cascade-delete such associations by itself when necessary. Only associations that do _not_ use RDBMS-level cascade handling would be sequenced, to make sure the referring entity is deleted before the referred-to one.

This assumption is wrong for `ON DELETE CASCADE`. The following examples give reasons why we need to also consider such associations, and in addition, we need to be able to deal with cycles formed by them.

In the following diagrams, `odc` means `ON DELETE CASCADE`, and `ref` is a regular foreign key with no extra `ON DELETE` semantics.

```mermaid
graph LR;
C-->|ref| B;
B-->|odc| A;
```

In this example, C must be removed before B and A. If we ignore the B->A dependency in the delete order computation, the result may not to be correct. ACB is not a working solution.

```mermaid
graph LR;
A-->|odc| B;
B-->|odc| A;
C-->|ref| B;
```

This is the situation in #10912. We have to deal with a cycle in the graph. C must be removed before A as well as B. If we ignore the B->A dependency (e.g. because we set it to "optional" to get away with the cycle), we might end up with an incorrect order ACB.

```mermaid
graph LR;
A-->|odc| B;
B-->|odc| A;
A-->|ref| C;
C-->|ref| B;
```

This example has no possible remove order. But, if we treat `odc` edges as optional, A -> C -> B would wrongly be deemed suitable.

```mermaid
graph LR;
A-->|ref| B;
B-->|odc| C;
C-->|odc| B;
D-->|ref| C;
```

Here, we must first remove A and D in any order; then, B and C in any order. If we treat one of the `odc` edges as optional, we might find the invalid solutions ABDC or DCAB.

#### Solution implemented in this PR

First, build a graph with a node for every to-be-removed entity, and edges for `ON DELETE CASCADE` associations between those entities. Then, use [Tarjan's algorithm](https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm) to find strongly connected components (SCCs) in this graph. The significance of SCCs is that whenever we remove one of the entities in a SCC from the database (no matter which one), the DBMS will immediately remove _all_ the other entities of that group as well.

For every SCC, pick one (arbitrary) entity from the group to represent all entities of that group. 

Then, build a second graph. Again we have nodes for all entities that are to be removed. This time, we insert edges for all regular (foreign key) associations and those with `ON DELETE CASCADE`. `ON DELETE SET NULL` can be left out. The edges are not added between the entities themselves, but between the entities representing the respective SCCs.

Also, for all non-trivial SCCs (those containing more than a single entity), add dependency edges to indicate that all entities of the SCC shall be processed _after_ the entity representing the group. This is to make sure we do not remove a SCC inadvertedly by removing one of its entities too early.

Run a topological sort on the second graph to get the actual delete order. Cycles in this second graph are a problem, there is no delete order.

Fixes #10912.
2024-01-12 22:44:07 +01:00
Grégoire Paris
a32578b7ea Merge pull request #11082 from bobvandevijver/eager-collection-iterable
Do not defer eager collection loading when in iteration context
2024-01-10 10:03:40 +01:00
Matthias Pigulla
e585a92763 Mention that `postRemove` may still see removed entities in in-memory collections (#11146)
... plus minor tweaks.
2024-01-02 21:31:28 +01:00
Grégoire Paris
26f47cb8d3 Merge pull request #11142 from greg0ire/remove-inheritance
Remove inheritance
2024-01-02 08:24:50 +01:00
Grégoire Paris
ebb101009c Remove inheritance
Spotted while trying to merge https://github.com/doctrine/orm/pull/11076
(among other things) up into 3.0.x. On that branch, it is no longer
possible for an entity to extend another entity without specifying an
inheritance mapping type.

I think the goal of that inheritance was just to reuse the identifier
anyway, so let's just duplicate the identifier declaration instead.
2023-12-28 19:59:02 +01:00
Grégoire Paris
f80ef66ffb Merge pull request #11134 from doctrine/no-private-fields-duplicate
Mention in the limitations that private field names cannot be reused
2023-12-22 17:35:27 +01:00
Matthias Pigulla
85d78f8b0d Mention in the limitations that private field names cannot be reused 2023-12-22 17:13:11 +01:00
Grégoire Paris
c2886478e8 Merge pull request #11086 from mpdude/11058-revisited
Avoid an inconsistency in topological sort result order
2023-12-21 22:51:39 +01:00
Matthias Pigulla
108fa30db2 Improve topological sort result order
This PR changes a detail in the commit order computation for depended-upon entities.

We have a parent-child relationship between two entity classes. The association is parent one-to-many children, with the child entities containing the (owning side) back-reference.

Cascade-persist is not used, so all entities have to be passed to `EntityManager::persist()`.

Before v2.16.0, two child entities C1 and C2 will be inserted in the same order in which they are passed to `persist()`, and that is regardless of whether the parent entity was passed to `persist()` before or after the child entities.

As of v2.16.0, passing the parent entity to `persist()` _after_ the child entities will lead to an insert order that is _reversed_ compared to the order of `persist()` calls.

This PR makes the order consistent in both cases, as it was before v2.16.0.

 #### Cause

When the parent is passed to `persist()` after the children, commit order computation has to re-arrange the entities. The parent must be inserted first since it is referred to by the children.

The implementation of the topological sort from #10547 processed entities in reverse `persist()` order and unshifted finished nodes to an array to obtain the final result. That leads to dependencies (parent → before c1, parent → before c2) showing up in the result in the reverse order of which they were added.

This PR changes the topological sort to produce a result in the opposite order ("all edges pointing left"), which helps to avoid the duplicate array order reversal.

 #### Discussion

* This PR _does not_ change semantics of the `persist()` so that entities would (under all ciscumstances) be inserted in the order of `persist()` calls.
* It fixes an unnecessary inconsistency between versions before 2.16.0 and after. In particular, it may be surprising that the insert order for the child entities depends on whether another referred-to entity (the parent) was added before or after them.
* _Both_ orders (c1 before or after c2) are technically and logically correct with regard to the agreement that `commit()` is free to arrange entities in a way that allows for efficient insertion into the database.

Fixes #11058.
2023-12-21 16:26:20 +01:00
Bob van de Vijver
e5ab18ff80 Do not defer eager loading when iterable hint is set 2023-11-23 13:04:13 +01:00
Bob van de Vijver
665ccf1376 Add failing test
This test show that eager collections are broken when used in conjuction
with iterating over a result.
2023-11-23 12:42:38 +01:00
19 changed files with 1208 additions and 113 deletions

View File

@@ -173,6 +173,19 @@ Events Overview
| :ref:`onClear<reference-events-on-clear>` | ``$em->clear()`` | No | `OnClearEventArgs`_ |
+-----------------------------------------------------------------+-----------------------+-----------+-------------------------------------+
.. warning::
Making changes to entities and calling ``EntityManager::flush()`` from within
event handlers dispatched by ``EntityManager::flush()`` itself is strongly
discouraged, and might be deprecated and eventually prevented in the future.
The reason is that it causes re-entrance into ``UnitOfWork::commit()`` while a commit
is currently being processed. The ``UnitOfWork`` was never designed to support this,
and its behavior in this situation is not covered by any tests.
This may lead to entity or collection updates being missed, applied only in parts and
changes being lost at the end of the commit phase.
Naming convention
~~~~~~~~~~~~~~~~~
@@ -699,30 +712,33 @@ Restrictions for this event:
postUpdate, postRemove, postPersist
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
These three post* events are called inside ``EntityManager::flush()``.
These three ``post*`` events are called inside ``EntityManager::flush()``.
Changes in here are not relevant to the persistence in the
database, but you can use these events to alter non-persistable items,
like non-mapped fields, logging or even associated classes that are
not directly mapped by Doctrine.
- The ``postUpdate`` event occurs after the database
update operations to entity data. It is not called for a DQL
``UPDATE`` statement.
update operations to entity data, but before the database transaction
has been committed. It is not called for a DQL ``UPDATE`` statement.
- The ``postPersist`` event occurs for an entity after the entity has
been made persistent. It will be invoked after all database insert
operations for new entities have been performed. Generated primary
key values will be available for all entities at the time this
event is triggered.
operations for new entities have been performed, but before the database
transaction has been committed. Generated primary key values will be
available for all entities at the time this event is triggered.
- The ``postRemove`` event occurs for an entity after the
entity has been deleted. It will be invoked after all database
delete operations for entity rows have been executed. This event is
not called for a DQL ``DELETE`` statement.
delete operations for entity rows have been executed, but before the
database transaction has been committed. This event is not called for
a DQL ``DELETE`` statement.
.. note::
At the time ``postPersist`` is called, there may still be collection and/or
"extra" updates pending. The database may not yet be completely in
sync with the entity states in memory, not even for the new entities.
sync with the entity states in memory, not even for the new entities. Similarly,
also at the time ``postUpdate`` and ``postRemove`` are called, in-memory collections
may still be in a "dirty" state or still contain removed entities.
.. warning::
@@ -731,19 +747,6 @@ not directly mapped by Doctrine.
cascade remove relations. In this case, you should load yourself the proxy in
the associated ``pre*`` event.
.. warning::
Making changes to entities and calling ``EntityManager::flush()`` from within
``post*`` event handlers is strongly discouraged, and might be deprecated and
eventually prevented in the future.
The reason is that it causes re-entrance into ``UnitOfWork::commit()`` while a commit
is currently being processed. The ``UnitOfWork`` was never designed to support this,
and its behavior in this situation is not covered by any tests.
This may lead to entity or collection updates being missed, applied only in parts and
changes being lost at the end of the commit phase.
.. _reference-events-post-load:
postLoad

View File

@@ -1,10 +1,10 @@
Limitations and Known Issues
============================
We try to make using Doctrine2 a very pleasant experience.
We try to make using Doctrine ORM a very pleasant experience.
Therefore we think it is very important to be honest about the
current limitations to our users. Much like every other piece of
software Doctrine2 is not perfect and far from feature complete.
software the ORM is not perfect and far from feature complete.
This section should give you an overview of current limitations of
Doctrine ORM as well as critical known issues that you should know
about.
@@ -175,6 +175,18 @@ due to complexity.
XML mapping configuration probably needs to completely re-configure or otherwise
copy-and-paste configuration for fields used from traits.
Mapping multiple private fields of the same name
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When two classes, say a mapped superclass and an entity inheriting from it,
both contain a ``private`` field of the same name, this will lead to a ``MappingException``.
Since the fields are ``private``, both are technically separate and can contain
different values at the same time. However, the ``ClassMetadata`` configuration used
internally by the ORM currently refers to fields by their name only, without taking the
class containing the field into consideration. This makes it impossible to keep separate
mapping configuration for both fields.
Known Issues
------------

View File

@@ -148,7 +148,6 @@
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="entity-result" type="orm:entity-result"/>
<xs:element name="column-result" type="orm:column-result"/>
<xs:any minOccurs="0" maxOccurs="unbounded" namespace="##other"/>
</xs:choice>
<xs:any minOccurs="0" maxOccurs="unbounded" namespace="##other"/>
</xs:choice>
@@ -226,22 +225,13 @@
<xs:complexType name="mapped-superclass" >
<xs:complexContent>
<xs:extension base="orm:entity">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:any minOccurs="0" maxOccurs="unbounded" namespace="##other"/>
</xs:choice>
<xs:anyAttribute namespace="##other"/>
</xs:extension>
<xs:extension base="orm:entity"/>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="embeddable">
<xs:complexContent>
<xs:extension base="orm:entity">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:any minOccurs="0" maxOccurs="unbounded" namespace="##other"/>
</xs:choice>
</xs:extension>
<xs:extension base="orm:entity"/>
</xs:complexContent>
</xs:complexType>
@@ -565,7 +555,6 @@
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="join-column" type="orm:join-column"/>
<xs:element name="join-columns" type="orm:join-columns"/>
<xs:any minOccurs="0" maxOccurs="unbounded" namespace="##other"/>
</xs:choice>
<xs:any minOccurs="0" maxOccurs="unbounded" namespace="##other"/>
</xs:choice>
@@ -583,7 +572,6 @@
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="join-column" type="orm:join-column"/>
<xs:element name="join-columns" type="orm:join-columns"/>
<xs:any minOccurs="0" maxOccurs="unbounded" namespace="##other"/>
</xs:choice>
<xs:any minOccurs="0" maxOccurs="unbounded" namespace="##other"/>
</xs:choice>

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Doctrine\ORM\Internal;
use InvalidArgumentException;
use function array_keys;
use function array_pop;
use function array_push;
use function min;
use function spl_object_id;
/**
* StronglyConnectedComponents implements Tarjan's algorithm to find strongly connected
* components (SCC) in a directed graph. This algorithm has a linear running time based on
* nodes (V) and edges between the nodes (E), resulting in a computational complexity
* of O(V + E).
*
* See https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
* for an explanation and the meaning of the DFS and lowlink numbers.
*
* @internal
*/
final class StronglyConnectedComponents
{
private const NOT_VISITED = 1;
private const IN_PROGRESS = 2;
private const VISITED = 3;
/**
* Array of all nodes, indexed by object ids.
*
* @var array<int, object>
*/
private $nodes = [];
/**
* DFS state for the different nodes, indexed by node object id and using one of
* this class' constants as value.
*
* @var array<int, self::*>
*/
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.
*
* @var array<int, array<int, bool>>
*/
private $edges = [];
/**
* DFS numbers, by object ID
*
* @var array<int, int>
*/
private $dfs = [];
/**
* lowlink numbers, by object ID
*
* @var array<int, int>
*/
private $lowlink = [];
/** @var int */
private $maxdfs = 0;
/**
* Nodes representing the SCC another node is in, indexed by lookup-node object ID
*
* @var array<int, object>
*/
private $representingNodes = [];
/**
* Stack with OIDs of nodes visited in the current state of the DFS
*
* @var list<int>
*/
private $stack = [];
/** @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
*/
public function addEdge($from, $to): void
{
$fromId = spl_object_id($from);
$toId = spl_object_id($to);
$this->edges[$fromId][$toId] = true;
}
public function findStronglyConnectedComponents(): void
{
foreach (array_keys($this->nodes) as $oid) {
if ($this->states[$oid] === self::NOT_VISITED) {
$this->tarjan($oid);
}
}
}
private function tarjan(int $oid): void
{
$this->dfs[$oid] = $this->lowlink[$oid] = $this->maxdfs++;
$this->states[$oid] = self::IN_PROGRESS;
array_push($this->stack, $oid);
foreach ($this->edges[$oid] as $adjacentId => $ignored) {
if ($this->states[$adjacentId] === self::NOT_VISITED) {
$this->tarjan($adjacentId);
$this->lowlink[$oid] = min($this->lowlink[$oid], $this->lowlink[$adjacentId]);
} elseif ($this->states[$adjacentId] === self::IN_PROGRESS) {
$this->lowlink[$oid] = min($this->lowlink[$oid], $this->dfs[$adjacentId]);
}
}
$lowlink = $this->lowlink[$oid];
if ($lowlink === $this->dfs[$oid]) {
$representingNode = null;
do {
$unwindOid = array_pop($this->stack);
if (! $representingNode) {
$representingNode = $this->nodes[$unwindOid];
}
$this->representingNodes[$unwindOid] = $representingNode;
$this->states[$unwindOid] = self::VISITED;
} while ($unwindOid !== $oid);
}
}
/**
* @param object $node
*
* @return object
*/
public function getNodeRepresentingStronglyConnectedComponent($node)
{
$oid = spl_object_id($node);
if (! isset($this->representingNodes[$oid])) {
throw new InvalidArgumentException('unknown node');
}
return $this->representingNodes[$oid];
}
}

View File

@@ -7,8 +7,6 @@ namespace Doctrine\ORM\Internal;
use Doctrine\ORM\Internal\TopologicalSort\CycleDetectedException;
use function array_keys;
use function array_reverse;
use function array_unshift;
use function spl_object_id;
/**
@@ -93,18 +91,14 @@ final class TopologicalSort
/**
* 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.
* A and B, then B will be listed before A in the result. Visually speaking, when ordering
* the nodes in the result order from left to right, all edges point to the left.
*
* @return list<object>
*/
public function sort(): array
{
/*
* 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) {
foreach (array_keys($this->nodes) as $oid) {
if ($this->states[$oid] === self::NOT_VISITED) {
$this->visit($oid);
}
@@ -147,7 +141,7 @@ final class TopologicalSort
}
// 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
// is to backtrack 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]);
@@ -160,6 +154,6 @@ final class TopologicalSort
// So we're done with this vertex as well.
$this->states[$oid] = self::VISITED;
array_unshift($this->sortResult, $this->nodes[$oid]);
$this->sortResult[] = $this->nodes[$oid];
}
}

View File

@@ -123,7 +123,7 @@ class ClassMetadataFactory extends AbstractClassMetadataFactory
if ($parent) {
$class->setInheritanceType($parent->inheritanceType);
$class->setDiscriminatorColumn($parent->discriminatorColumn);
$this->inheritIdGeneratorMapping($class, $parent);
$class->setIdGeneratorType($parent->generatorType);
$this->addInheritedFields($class, $parent);
$this->addInheritedRelations($class, $parent);
$this->addInheritedEmbeddedClasses($class, $parent);
@@ -151,8 +151,12 @@ class ClassMetadataFactory extends AbstractClassMetadataFactory
throw MappingException::reflectionFailure($class->getName(), $e);
}
// Complete id generator mapping when the generator was declared/added in this class
if ($class->identifier && (! $parent || ! $parent->identifier)) {
// If this class has a parent the id generator strategy is inherited.
// However this is only true if the hierarchy of parents contains the root entity,
// if it consists of mapped superclasses these don't necessarily include the id field.
if ($parent && $rootEntityFound) {
$this->inheritIdGeneratorMapping($class, $parent);
} else {
$this->completeIdGeneratorMapping($class);
}

View File

@@ -2558,6 +2558,10 @@ class ClassMetadataInfo implements ClassMetadata
$overrideMapping['id'] = $mapping['id'];
}
if (isset($mapping['declared'])) {
$overrideMapping['declared'] = $mapping['declared'];
}
if (! isset($overrideMapping['type'])) {
$overrideMapping['type'] = $mapping['type'];
}

View File

@@ -28,6 +28,7 @@ use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Exception\UnexpectedAssociationValue;
use Doctrine\ORM\Id\AssignedGenerator;
use Doctrine\ORM\Internal\HydrationCompleteHandler;
use Doctrine\ORM\Internal\StronglyConnectedComponents;
use Doctrine\ORM\Internal\TopologicalSort;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\MappingException;
@@ -1378,9 +1379,10 @@ class UnitOfWork implements PropertyChangedListener
$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);
// Add dependency. The dependency direction implies that "$entity depends on $targetEntity". The
// topological sort result will output the depended-upon nodes first, which means we can insert
// entities in that order.
$sort->addEdge($entity, $targetEntity, $isNullable);
}
}
@@ -1390,14 +1392,19 @@ class UnitOfWork implements PropertyChangedListener
/** @return list<object> */
private function computeDeleteExecutionOrder(): array
{
$sort = new TopologicalSort();
$stronglyConnectedComponents = new StronglyConnectedComponents();
$sort = new TopologicalSort();
// First make sure we have all the nodes
foreach ($this->entityDeletions as $entity) {
$stronglyConnectedComponents->addNode($entity);
$sort->addNode($entity);
}
// Now add edges
// First, consider only "on delete cascade" associations between entities
// and find strongly connected groups. Once we delete any one of the entities
// in such a group, _all_ of the other entities will be removed as well. So,
// we need to treat those groups like a single entity when performing delete
// order topological sorting.
foreach ($this->entityDeletions as $entity) {
$class = $this->em->getClassMetadata(get_class($entity));
@@ -1409,16 +1416,65 @@ class UnitOfWork implements PropertyChangedListener
continue;
}
// For associations that implement a database-level cascade/set null operation,
assert(isset($assoc['joinColumns']));
$joinColumns = reset($assoc['joinColumns']);
if (! isset($joinColumns['onDelete'])) {
continue;
}
$onDeleteOption = strtolower($joinColumns['onDelete']);
if ($onDeleteOption !== 'cascade') {
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 || ! $stronglyConnectedComponents->hasNode($targetEntity)) {
continue;
}
$stronglyConnectedComponents->addEdge($entity, $targetEntity);
}
}
$stronglyConnectedComponents->findStronglyConnectedComponents();
// Now do the actual topological sorting to find the delete order.
foreach ($this->entityDeletions as $entity) {
$class = $this->em->getClassMetadata(get_class($entity));
// Get the entities representing the SCC
$entityComponent = $stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($entity);
// When $entity is part of a non-trivial strongly connected component group
// (a group containing not only those entities alone), make sure we process it _after_ the
// entity representing the group.
// The dependency direction implies that "$entity depends on $entityComponent
// being deleted first". The topological sort will output the depended-upon nodes first.
if ($entityComponent !== $entity) {
$sort->addEdge($entity, $entityComponent, false);
}
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;
}
// For associations that implement a database-level 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.
// deleted first, the DBMS will temporarily set the foreign key to NULL (SET NULL).
// So, 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') {
if ($onDeleteOption === 'set null') {
continue;
}
}
@@ -1432,9 +1488,17 @@ class UnitOfWork implements PropertyChangedListener
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);
// Get the entities representing the SCC
$targetEntityComponent = $stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($targetEntity);
// When we have a dependency between two different groups of strongly connected nodes,
// add it to the computation.
// The dependency direction implies that "$targetEntityComponent depends on $entityComponent
// being deleted first". The topological sort will output the depended-upon nodes first,
// so we can work through the result in the returned order.
if ($targetEntityComponent !== $entityComponent) {
$sort->addEdge($targetEntityComponent, $entityComponent, false);
}
}
}
@@ -3104,9 +3168,10 @@ EXCEPTION
$reflField->setValue($entity, $pColl);
if ($hints['fetchMode'][$class->name][$field] === ClassMetadata::FETCH_EAGER) {
if ($assoc['type'] === ClassMetadata::ONE_TO_MANY) {
$isIteration = isset($hints[Query::HINT_INTERNAL_ITERATION]) && $hints[Query::HINT_INTERNAL_ITERATION];
if (! $isIteration && $assoc['type'] === ClassMetadata::ONE_TO_MANY) {
$this->scheduleCollectionForBatchLoading($pColl, $class);
} elseif ($assoc['type'] === ClassMetadata::MANY_TO_MANY) {
} elseif (($isIteration && $assoc['type'] === ClassMetadata::ONE_TO_MANY) || $assoc['type'] === ClassMetadata::MANY_TO_MANY) {
$this->loadCollection($pColl);
$pColl->takeSnapshot();
}

View File

@@ -84,6 +84,18 @@ class EagerFetchCollectionTest extends OrmFunctionalTestCase
$query->getResult();
}
public function testEagerFetchWithIterable(): void
{
$this->createOwnerWithChildren(2);
$this->_em->flush();
$this->_em->clear();
$iterable = $this->_em->getRepository(EagerFetchOwner::class)->createQueryBuilder('o')->getQuery()->toIterable();
$owner = $iterable->current();
$this->assertCount(2, $owner->children);
}
protected function createOwnerWithChildren(int $children): EagerFetchOwner
{
$owner = new EagerFetchOwner();

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Functional\Ticket;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Tests\OrmFunctionalTestCase;
use function array_filter;
use function array_values;
use function strpos;
class GH10912Test extends OrmFunctionalTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->setUpEntitySchema([
GH10912User::class,
GH10912Profile::class,
GH10912Room::class,
]);
}
public function testIssue(): void
{
$user = new GH10912User();
$profile = new GH10912Profile();
$room = new GH10912Room();
$user->rooms->add($room);
$user->profile = $profile;
$profile->user = $user;
$room->user = $user;
$this->_em->persist($room);
$this->_em->persist($user);
$this->_em->persist($profile);
$this->_em->flush();
/*
* This issue is about finding a special deletion order:
* $user and $profile cross-reference each other with ON DELETE CASCADE.
* So, whichever one gets deleted first, the DBMS will immediately dispose
* of the other one as well.
*
* $user -> $room is the unproblematic (irrelevant) inverse side of
* a OneToMany association.
*
* $room -> $user is a not-nullable, no DBMS-level-cascade, owning side
* of ManyToOne. We *must* remove the $room _before_ the $user can be
* deleted. And remember, $user deletion happens either when we DELETE the
* user (direct deletion), or when we delete the $profile (ON DELETE CASCADE
* propagates to the user).
*
* In the original bug report, the ordering of fields in the entities was
* relevant, in combination with a cascade=persist configuration.
*
* But, for the sake of clarity, let's put these features away and create
* the problematic sequence in UnitOfWork::$entityDeletions directly:
*/
$this->_em->remove($profile);
$this->_em->remove($user);
$this->_em->remove($room);
$queryLog = $this->getQueryLog();
$queryLog->reset()->enable();
$this->_em->flush();
$queries = array_values(array_filter($queryLog->queries, static function (array $entry): bool {
return strpos($entry['sql'], 'DELETE') === 0;
}));
self::assertCount(3, $queries);
// we do not care about the order of $user vs. $profile, so do not check them.
self::assertSame('DELETE FROM GH10912Room WHERE id = ?', $queries[0]['sql'], '$room deletion is the first query');
// The EntityManager is aware that all three entities have been deleted (sanity check)
$im = $this->_em->getUnitOfWork()->getIdentityMap();
self::assertEmpty($im[GH10912Profile::class]);
self::assertEmpty($im[GH10912User::class]);
self::assertEmpty($im[GH10912Room::class]);
}
}
/** @ORM\Entity */
class GH10912User
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*
* @var int
*/
public $id;
/**
* @ORM\OneToMany(targetEntity=GH10912Room::class, mappedBy="user")
*
* @var Collection<int, GH10912Room>
*/
public $rooms;
/**
* @ORM\OneToOne(targetEntity=GH10912Profile::class)
* @ORM\JoinColumn(onDelete="cascade")
*
* @var GH10912Profile
*/
public $profile;
public function __construct()
{
$this->rooms = new ArrayCollection();
}
}
/** @ORM\Entity */
class GH10912Profile
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*
* @var int
*/
public $id;
/**
* @ORM\OneToOne(targetEntity=GH10912User::class)
* @ORM\JoinColumn(onDelete="cascade")
*
* @var GH10912User
*/
public $user;
}
/** @ORM\Entity */
class GH10912Room
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*
* @var int
*/
public $id;
/**
* @ORM\ManyToOne(targetEntity=GH10912User::class, inversedBy="rooms")
* @ORM\JoinColumn(nullable=false)
*
* @var GH10912User
*/
public $user;
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Functional\Ticket;
use Doctrine\ORM\Internal\TopologicalSort\CycleDetectedException;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Tests\OrmFunctionalTestCase;
use function array_filter;
use function array_values;
use function strpos;
class GH10913Test extends OrmFunctionalTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->setUpEntitySchema([
GH10913Entity::class,
]);
}
public function testExample1(): void
{
[$a, $b, $c] = $this->createEntities(3);
$c->ref = $b;
$b->odc = $a;
$this->_em->persist($a);
$this->_em->persist($b);
$this->_em->persist($c);
$this->_em->flush();
$this->_em->remove($a);
$this->_em->remove($b);
$this->_em->remove($c);
$this->flushAndAssertNumberOfDeleteQueries(3);
}
public function testExample2(): void
{
[$a, $b, $c] = $this->createEntities(3);
$a->odc = $b;
$b->odc = $a;
$c->ref = $b;
$this->_em->persist($a);
$this->_em->persist($b);
$this->_em->persist($c);
$this->_em->flush();
$this->_em->remove($a);
$this->_em->remove($b);
$this->_em->remove($c);
$this->flushAndAssertNumberOfDeleteQueries(3);
}
public function testExample3(): void
{
[$a, $b, $c] = $this->createEntities(3);
$a->odc = $b;
$a->ref = $c;
$c->ref = $b;
$b->odc = $a;
$this->_em->persist($a);
$this->_em->persist($b);
$this->_em->persist($c);
$this->_em->flush();
$this->_em->remove($a);
$this->_em->remove($b);
$this->_em->remove($c);
self::expectException(CycleDetectedException::class);
$this->_em->flush();
}
public function testExample4(): void
{
[$a, $b, $c, $d] = $this->createEntities(4);
$a->ref = $b;
$b->odc = $c;
$c->odc = $b;
$d->ref = $c;
$this->_em->persist($a);
$this->_em->persist($b);
$this->_em->persist($c);
$this->_em->persist($d);
$this->_em->flush();
$this->_em->remove($b);
$this->_em->remove($c);
$this->_em->remove($d);
$this->_em->remove($a);
$this->flushAndAssertNumberOfDeleteQueries(4);
}
private function flushAndAssertNumberOfDeleteQueries(int $expectedCount): void
{
$queryLog = $this->getQueryLog();
$queryLog->reset()->enable();
$this->_em->flush();
$queries = array_values(array_filter($queryLog->queries, static function (array $entry): bool {
return strpos($entry['sql'], 'DELETE') === 0;
}));
self::assertCount($expectedCount, $queries);
}
/**
* @return list<GH10913Entity>
*/
private function createEntities(int $count = 1): array
{
$result = [];
for ($i = 0; $i < $count; $i++) {
$result[] = new GH10913Entity();
}
return $result;
}
}
/** @ORM\Entity */
class GH10913Entity
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*
* @var int
*/
public $id;
/**
* @ORM\ManyToOne(targetEntity=GH10913Entity::class)
* @ORM\JoinColumn(nullable=true, onDelete="CASCADE")
*
* @var GH10913Entity
*/
public $odc;
/**
* @ORM\ManyToOne(targetEntity=GH10913Entity::class)
* @ORM\JoinColumn(nullable=true)
*
* @var GH10913Entity
*/
public $ref;
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Functional\Ticket;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Tests\OrmFunctionalTestCase;
/**
* @group GH-10927
*/
class GH10927Test extends OrmFunctionalTestCase
{
protected function setUp(): void
{
parent::setUp();
$platform = $this->_em->getConnection()->getDatabasePlatform();
if (! $platform instanceof PostgreSQLPlatform) {
self::markTestSkipped('The ' . self::class . ' requires the use of postgresql.');
}
$this->setUpEntitySchema([
GH10927RootMappedSuperclass::class,
GH10927InheritedMappedSuperclass::class,
GH10927EntityA::class,
GH10927EntityB::class,
GH10927EntityC::class,
]);
}
public function testSequenceGeneratorDefinitionForRootMappedSuperclass(): void
{
$metadata = $this->_em->getClassMetadata(GH10927RootMappedSuperclass::class);
self::assertNull($metadata->sequenceGeneratorDefinition);
}
public function testSequenceGeneratorDefinitionForEntityA(): void
{
$metadata = $this->_em->getClassMetadata(GH10927EntityA::class);
self::assertSame('GH10927EntityA_id_seq', $metadata->sequenceGeneratorDefinition['sequenceName']);
}
public function testSequenceGeneratorDefinitionForInheritedMappedSuperclass(): void
{
$metadata = $this->_em->getClassMetadata(GH10927InheritedMappedSuperclass::class);
self::assertSame('GH10927InheritedMappedSuperclass_id_seq', $metadata->sequenceGeneratorDefinition['sequenceName']);
}
public function testSequenceGeneratorDefinitionForEntityB(): void
{
$metadata = $this->_em->getClassMetadata(GH10927EntityB::class);
self::assertSame('GH10927EntityB_id_seq', $metadata->sequenceGeneratorDefinition['sequenceName']);
}
public function testSequenceGeneratorDefinitionForEntityC(): void
{
$metadata = $this->_em->getClassMetadata(GH10927EntityC::class);
self::assertSame('GH10927EntityB_id_seq', $metadata->sequenceGeneratorDefinition['sequenceName']);
}
}
/**
* @ORM\MappedSuperclass()
*/
class GH10927RootMappedSuperclass
{
}
/**
* @ORM\Entity()
*/
class GH10927EntityA extends GH10927RootMappedSuperclass
{
/**
* @ORM\Id
* @ORM\GeneratedValue(strategy="SEQUENCE")
* @ORM\Column(type="integer")
*
* @var int|null
*/
private $id = null;
}
/**
* @ORM\MappedSuperclass()
*/
class GH10927InheritedMappedSuperclass extends GH10927RootMappedSuperclass
{
/**
* @ORM\Id
* @ORM\GeneratedValue(strategy="SEQUENCE")
* @ORM\Column(type="integer")
*
* @var int|null
*/
private $id = null;
}
/**
* @ORM\Entity()
* @ORM\InheritanceType("JOINED")
* @ORM\DiscriminatorColumn(name="discr", type="string")
* @ORM\DiscriminatorMap({"B" = "GH10927EntityB", "C" = "GH10927EntityC"})
*/
class GH10927EntityB extends GH10927InheritedMappedSuperclass
{
}
/**
* @ORM\Entity()
*/
class GH10927EntityC extends GH10927EntityB
{
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Functional\Ticket;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Tests\OrmFunctionalTestCase;
class GH11058Test extends OrmFunctionalTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->setUpEntitySchema([
GH11058Parent::class,
GH11058Child::class,
]);
}
public function testChildrenInsertedInOrderOfPersistCalls1WhenParentPersistedLast(): void
{
[$parent, $child1, $child2] = $this->createParentWithTwoChildEntities();
$this->_em->persist($child1);
$this->_em->persist($child2);
$this->_em->persist($parent);
$this->_em->flush();
self::assertTrue($child1->id < $child2->id);
}
public function testChildrenInsertedInOrderOfPersistCalls2WhenParentPersistedLast(): void
{
[$parent, $child1, $child2] = $this->createParentWithTwoChildEntities();
$this->_em->persist($child2);
$this->_em->persist($child1);
$this->_em->persist($parent);
$this->_em->flush();
self::assertTrue($child2->id < $child1->id);
}
public function testChildrenInsertedInOrderOfPersistCalls1WhenParentPersistedFirst(): void
{
[$parent, $child1, $child2] = $this->createParentWithTwoChildEntities();
$this->_em->persist($parent);
$this->_em->persist($child1);
$this->_em->persist($child2);
$this->_em->flush();
self::assertTrue($child1->id < $child2->id);
}
public function testChildrenInsertedInOrderOfPersistCalls2WhenParentPersistedFirst(): void
{
[$parent, $child1, $child2] = $this->createParentWithTwoChildEntities();
$this->_em->persist($parent);
$this->_em->persist($child2);
$this->_em->persist($child1);
$this->_em->flush();
self::assertTrue($child2->id < $child1->id);
}
private function createParentWithTwoChildEntities(): array
{
$parent = new GH11058Parent();
$child1 = new GH11058Child();
$child2 = new GH11058Child();
$parent->addChild($child1);
$parent->addChild($child2);
return [$parent, $child1, $child2];
}
}
/**
* @ORM\Entity()
*/
class GH11058Parent
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*
* @var int
*/
public $id;
/**
* @ORM\OneToMany(targetEntity="GH11058Child", mappedBy="parent")
*
* @var Collection<int, GH11058Child>
*/
public $children;
public function __construct()
{
$this->children = new ArrayCollection();
}
public function addChild(GH11058Child $child): void
{
if (! $this->children->contains($child)) {
$this->children->add($child);
$child->setParent($this);
}
}
}
/**
* @ORM\Entity()
*/
class GH11058Child
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*
* @var int
*/
public $id;
/**
* @ORM\ManyToOne(targetEntity="GH11058Parent", inversedBy="children")
*
* @var GH11058Parent
*/
public $parent;
public function setParent(GH11058Parent $parent): void
{
$this->parent = $parent;
$parent->addChild($this);
}
}

View File

@@ -6,12 +6,22 @@ namespace Doctrine\Tests\ORM\Functional\Ticket\GH11072;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
/**
* @Entity
*/
class GH11072EntityAdvanced extends GH11072EntityBasic
class GH11072EntityAdvanced
{
/**
* @Id
* @Column(type="integer")
* @GeneratedValue
* @var int
*/
public $id;
/** @Column(type="json") */
public mixed $anything;

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Functional\Ticket;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Tests\OrmFunctionalTestCase;
class GH11135Test extends OrmFunctionalTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->setUpEntitySchema([
GH11135MappedSuperclass::class,
GH11135EntityWithOverride::class,
GH11135EntityWithoutOverride::class,
]);
}
public function testOverrideInheritsDeclaringClass(): void
{
$cm1 = $this->_em->getClassMetadata(GH11135EntityWithOverride::class);
$cm2 = $this->_em->getClassMetadata(GH11135EntityWithoutOverride::class);
self::assertSame($cm1->getFieldMapping('id')['declared'], $cm2->getFieldMapping('id')['declared']);
self::assertSame($cm1->getAssociationMapping('ref')['declared'], $cm2->getAssociationMapping('ref')['declared']);
}
}
/**
* @ORM\MappedSuperclass
*/
class GH11135MappedSuperclass
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*
* @var int
*/
private $id;
/**
* @ORM\ManyToOne(targetEntity="GH11135EntityWithoutOverride")
*
* @var GH11135EntityWithoutOverride
*/
private $ref;
}
/**
* @ORM\Entity()
* @ORM\AttributeOverrides({
* @ORM\AttributeOverride(name="id", column=@ORM\Column(name="id_overridden"))
* })
* @ORM\AssociationOverrides({
* @ORM\AssociationOverride(name="ref", joinColumns=@ORM\JoinColumn(name="ref_overridden", referencedColumnName="id"))
* })
*/
class GH11135EntityWithOverride extends GH11135MappedSuperclass
{
}
/**
* @ORM\Entity()
*/
class GH11135EntityWithoutOverride extends GH11135MappedSuperclass
{
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Internal;
use Doctrine\ORM\Internal\StronglyConnectedComponents;
use Doctrine\Tests\OrmTestCase;
class StronglyConnectedComponentsTest extends OrmTestCase
{
/** @var array<string, Node> */
private $nodes = [];
/** @var StronglyConnectedComponents */
private $stronglyConnectedComponents;
protected function setUp(): void
{
$this->stronglyConnectedComponents = new StronglyConnectedComponents();
}
public function testFindStronglyConnectedComponents(): void
{
// A -> B <-> C -> D <-> E
$this->addNodes('A', 'B', 'C', 'D', 'E');
$this->addEdge('A', 'B');
$this->addEdge('B', 'C');
$this->addEdge('C', 'B');
$this->addEdge('C', 'D');
$this->addEdge('D', 'E');
$this->addEdge('E', 'D');
$this->stronglyConnectedComponents->findStronglyConnectedComponents();
$this->assertNodesAreInSameComponent('B', 'C');
$this->assertNodesAreInSameComponent('D', 'E');
$this->assertNodesAreNotInSameComponent('A', 'B');
$this->assertNodesAreNotInSameComponent('A', 'D');
}
public function testFindStronglyConnectedComponents2(): void
{
// A -> B -> C -> D -> B
$this->addNodes('A', 'B', 'C', 'D');
$this->addEdge('A', 'B');
$this->addEdge('B', 'C');
$this->addEdge('C', 'D');
$this->addEdge('D', 'B');
$this->stronglyConnectedComponents->findStronglyConnectedComponents();
$this->assertNodesAreInSameComponent('B', 'C');
$this->assertNodesAreInSameComponent('C', 'D');
$this->assertNodesAreNotInSameComponent('A', 'B');
}
public function testFindStronglyConnectedComponents3(): void
{
// v---------.
// A -> B -> C -> D -> E
// ^--------´
$this->addNodes('A', 'B', 'C', 'D', 'E');
$this->addEdge('A', 'B');
$this->addEdge('B', 'C');
$this->addEdge('C', 'D');
$this->addEdge('D', 'E');
$this->addEdge('E', 'C');
$this->addEdge('D', 'B');
$this->stronglyConnectedComponents->findStronglyConnectedComponents();
$this->assertNodesAreInSameComponent('B', 'C');
$this->assertNodesAreInSameComponent('C', 'D');
$this->assertNodesAreInSameComponent('D', 'E');
$this->assertNodesAreInSameComponent('E', 'B');
$this->assertNodesAreNotInSameComponent('A', 'B');
}
private function addNodes(string ...$names): void
{
foreach ($names as $name) {
$node = new Node($name);
$this->nodes[$name] = $node;
$this->stronglyConnectedComponents->addNode($node);
}
}
private function addEdge(string $from, string $to, bool $optional = false): void
{
$this->stronglyConnectedComponents->addEdge($this->nodes[$from], $this->nodes[$to], $optional);
}
private function assertNodesAreInSameComponent(string $first, string $second): void
{
self::assertSame(
$this->stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($this->nodes[$first]),
$this->stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($this->nodes[$second])
);
}
private function assertNodesAreNotInSameComponent(string $first, string $second): void
{
self::assertNotSame(
$this->stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($this->nodes[$first]),
$this->stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($this->nodes[$second])
);
}
}

View File

@@ -34,7 +34,7 @@ class TopologicalSortTest extends OrmTestCase
$this->addEdge('E', 'A');
// There is only 1 valid ordering for this constellation
self::assertSame(['E', 'A', 'B', 'C'], $this->computeResult());
self::assertSame(['C', 'B', 'A', 'E'], $this->computeResult());
}
public function testSkipOptionalEdgeToBreakCycle(): void
@@ -44,7 +44,7 @@ class TopologicalSortTest extends OrmTestCase
$this->addEdge('A', 'B', true);
$this->addEdge('B', 'A', false);
self::assertSame(['B', 'A'], $this->computeResult());
self::assertSame(['A', 'B'], $this->computeResult());
}
public function testBreakCycleByBacktracking(): void
@@ -57,7 +57,7 @@ class TopologicalSortTest extends OrmTestCase
$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());
self::assertSame(['B', 'A', 'D', 'C'], $this->computeResult());
}
public function testCycleRemovedByEliminatingLastOptionalEdge(): void
@@ -75,7 +75,7 @@ class TopologicalSortTest extends OrmTestCase
$this->addEdge('B', 'D', true);
$this->addEdge('D', 'A');
self::assertSame(['C', 'D', 'A', 'B'], $this->computeResult());
self::assertSame(['B', 'A', 'C', 'D'], $this->computeResult());
}
public function testGH7180Example(): void
@@ -89,7 +89,7 @@ class TopologicalSortTest extends OrmTestCase
$this->addEdge('F', 'E');
$this->addEdge('E', 'D');
self::assertSame(['F', 'E', 'D', 'G'], $this->computeResult());
self::assertSame(['G', 'D', 'E', 'F'], $this->computeResult());
}
public function testCommitOrderingFromGH7259Test(): void
@@ -106,9 +106,9 @@ class TopologicalSortTest extends OrmTestCase
// 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'],
['C', 'B', 'A', 'D'],
['B', 'C', 'A', 'D'],
['B', 'A', 'C', 'D'],
];
self::assertContains($this->computeResult(), $correctOrders);
@@ -124,12 +124,12 @@ class TopologicalSortTest extends OrmTestCase
$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).
// Many orderings are possible here, but the bottom line is A must be before D (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);
self::assertTrue($indexD > $indexA);
}
public function testCommitOrderingFromGH8349Case2Test(): void
@@ -141,7 +141,7 @@ class TopologicalSortTest extends OrmTestCase
$this->addEdge('A', 'B', true);
// The B -> A requirement determines the result here
self::assertSame(['B', 'A'], $this->computeResult());
self::assertSame(['A', 'B'], $this->computeResult());
}
public function testNodesMaintainOrderWhenNoDepencency(): void
@@ -153,6 +153,58 @@ class TopologicalSortTest extends OrmTestCase
self::assertSame(['A', 'B', 'C'], $this->computeResult());
}
public function testNodesReturnedInDepthFirstOrder(): void
{
$this->addNodes('A', 'B', 'C');
$this->addEdge('A', 'B');
$this->addEdge('A', 'C');
// We start on A and find that it has two dependencies on B and C,
// added (as dependencies) in that order.
// So, first we continue the DFS on B, because that edge was added first.
// This gives the result order B, C, A.
self::assertSame(['B', 'C', 'A'], $this->computeResult());
}
public function testNodesReturnedInDepthFirstOrderWithEdgesInDifferentOrderThanNodes(): void
{
$this->addNodes('A', 'B', 'C');
$this->addEdge('A', 'C');
$this->addEdge('A', 'B');
// This is like testNodesReturnedInDepthFirstOrder, but it shows that for the two
// nodes B and C that A depends upon, the result will follow the order in which
// the edges were added.
self::assertSame(['C', 'B', 'A'], $this->computeResult());
}
public function testNodesReturnedInDepthFirstOrderWithDependingNodeLast(): void
{
$this->addNodes('B', 'C', 'A');
$this->addEdge('A', 'B');
$this->addEdge('A', 'C');
// This again is like testNodesReturnedInDepthFirstOrder, but this
// time the node A that depends on B and C is added as the last node.
// That means processing can go over B and C in the order they were given.
// The order in which edges are added is not relevant (!), since at the time
// the edges are evaluated, the nodes they point to have already been finished.
self::assertSame(['B', 'C', 'A'], $this->computeResult());
}
public function testNodesReturnedInDepthFirstOrderWithDependingNodeLastAndEdgeOrderInversed(): void
{
$this->addNodes('B', 'C', 'A');
$this->addEdge('A', 'C');
$this->addEdge('A', 'B');
// This again is like testNodesReturnedInDepthFirstOrderWithDependingNodeLast, but adds
// the edges in the opposing order. Still, the result order is the same (!).
// This may be surprising when comparing with testNodesReturnedInDepthFirstOrderWithEdgesInDifferentOrderThanNodes,
// where the result order depends upon the _edge_ order.
self::assertSame(['B', 'C', 'A'], $this->computeResult());
}
public function testDetectSmallCycle(): void
{
$this->addNodes('A', 'B');
@@ -205,7 +257,7 @@ class TopologicalSortTest extends OrmTestCase
$this->computeResult();
} catch (CycleDetectedException $exception) {
self::assertEquals(
[$this->nodes['D'], $this->nodes['B'], $this->nodes['C'], $this->nodes['D']],
[$this->nodes['B'], $this->nodes['C'], $this->nodes['D'], $this->nodes['B']],
$exception->getCycle()
);
}

View File

@@ -163,22 +163,7 @@ class BasicInheritanceMappingTest extends OrmTestCase
/**
* @group DDC-1156
* @group DDC-1218
*/
public function testGeneratedValueFromMappedSuperclass(): void
{
$class = $this->cmf->getMetadataFor(SuperclassEntity::class);
assert($class instanceof ClassMetadata);
self::assertInstanceOf(IdSequenceGenerator::class, $class->idGenerator);
self::assertEquals(
['allocationSize' => 1, 'initialValue' => 10, 'sequenceName' => 'foo'],
$class->sequenceGeneratorDefinition
);
}
/**
* @group DDC-1156
* @group DDC-1218
* @group GH-10927
*/
public function testSequenceDefinitionInHierarchyWithSandwichMappedSuperclass(): void
{
@@ -192,22 +177,6 @@ class BasicInheritanceMappingTest extends OrmTestCase
);
}
/**
* @group DDC-1156
* @group DDC-1218
*/
public function testMultipleMappedSuperclasses(): void
{
$class = $this->cmf->getMetadataFor(MediumSuperclassEntity::class);
assert($class instanceof ClassMetadata);
self::assertInstanceOf(IdSequenceGenerator::class, $class->idGenerator);
self::assertEquals(
['allocationSize' => 1, 'initialValue' => 10, 'sequenceName' => 'foo'],
$class->sequenceGeneratorDefinition
);
}
/**
* Ensure indexes are inherited from the mapped superclass.
*

View File

@@ -29,6 +29,8 @@ use Doctrine\Tests\Models\DDC117\DDC117ArticleDetails;
use Doctrine\Tests\Models\DDC6412\DDC6412File;
use Doctrine\Tests\Models\DDC964\DDC964Admin;
use Doctrine\Tests\Models\DDC964\DDC964Guest;
use Doctrine\Tests\Models\DirectoryTree\AbstractContentItem;
use Doctrine\Tests\Models\DirectoryTree\Directory;
use Doctrine\Tests\Models\Routing\RoutingLeg;
use Doctrine\Tests\Models\TypedProperties;
use Doctrine\Tests\ORM\Mapping\TypedFieldMapper\CustomIntAsStringTypedFieldMapper;
@@ -1186,6 +1188,30 @@ class ClassMetadataTest extends OrmTestCase
$cm->setAttributeOverride('name', ['type' => 'date']);
}
public function testAttributeOverrideKeepsDeclaringClass(): void
{
$cm = new ClassMetadata(Directory::class);
$cm->mapField(['fieldName' => 'id', 'type' => 'integer', 'declared' => AbstractContentItem::class]);
$cm->setAttributeOverride('id', ['columnName' => 'new_id']);
$mapping = $cm->getFieldMapping('id');
self::assertArrayHasKey('declared', $mapping);
self::assertSame(AbstractContentItem::class, $mapping['declared']);
}
public function testAssociationOverrideKeepsDeclaringClass(): void
{
$cm = new ClassMetadata(Directory::class);
$cm->mapManyToOne(['fieldName' => 'parentDirectory', 'targetEntity' => Directory::class, 'cascade' => ['remove'], 'declared' => Directory::class]);
$cm->setAssociationOverride('parentDirectory', ['cascade' => '']);
$mapping = $cm->getAssociationMapping('parentDirectory');
self::assertArrayHasKey('declared', $mapping);
self::assertSame(Directory::class, $mapping['declared']);
}
/** @group DDC-1955 */
public function testInvalidEntityListenerClassException(): void
{