mirror of
https://github.com/doctrine/orm.git
synced 2026-03-24 15:02:22 +01:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccfc97c32f | ||
|
|
d386b43be3 | ||
|
|
0970ce7072 | ||
|
|
624c56be72 | ||
|
|
fbc8e6741e | ||
|
|
7151db3cb8 | ||
|
|
ac24c11808 | ||
|
|
dd478d8662 | ||
|
|
0b3cd72609 | ||
|
|
85034699cb | ||
|
|
d98186e2c4 | ||
|
|
a0ed37954b | ||
|
|
4875f4c878 | ||
|
|
398ab0547a | ||
|
|
8f15337b03 | ||
|
|
a8632aca8f | ||
|
|
3dd3d38857 | ||
|
|
c6b3509aa9 | ||
|
|
a32578b7ea | ||
|
|
e585a92763 | ||
|
|
26f47cb8d3 | ||
|
|
ebb101009c | ||
|
|
f80ef66ffb | ||
|
|
85d78f8b0d | ||
|
|
c2886478e8 | ||
|
|
108fa30db2 | ||
|
|
e5ab18ff80 | ||
|
|
665ccf1376 |
22
README.md
22
README.md
@@ -1,7 +1,7 @@
|
||||
| [3.0.x][3.0] | [2.16.x][2.16] | [2.15.x][2.15] |
|
||||
| [3.0.x][3.0] | [2.18.x][2.18] | [2.17.x][2.17] |
|
||||
|:----------------:|:----------------:|:----------:|
|
||||
| [![Build status][3.0 image]][3.0] | [![Build status][2.16 image]][2.16] | [![Build status][2.15 image]][2.15] |
|
||||
| [![Coverage Status][3.0 coverage image]][3.0 coverage]| [![Coverage Status][2.16 coverage image]][2.16 coverage] | [![Coverage Status][2.15 coverage image]][2.15 coverage] |
|
||||
| [![Build status][3.0 image]][3.0] | [![Build status][2.18 image]][2.18] | [![Build status][2.17 image]][2.17] |
|
||||
| [![Coverage Status][3.0 coverage image]][3.0 coverage]| [![Coverage Status][2.18 coverage image]][2.18 coverage] | [![Coverage Status][2.17 coverage image]][2.17 coverage] |
|
||||
|
||||
[<h1 align="center">🇺🇦 UKRAINE NEEDS YOUR HELP NOW!</h1>](https://www.doctrine-project.org/stop-war.html)
|
||||
|
||||
@@ -22,11 +22,11 @@ without requiring unnecessary code duplication.
|
||||
[3.0]: https://github.com/doctrine/orm/tree/3.0.x
|
||||
[3.0 coverage image]: https://codecov.io/gh/doctrine/orm/branch/3.0.x/graph/badge.svg
|
||||
[3.0 coverage]: https://codecov.io/gh/doctrine/orm/branch/3.0.x
|
||||
[2.16 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=2.16.x
|
||||
[2.16]: https://github.com/doctrine/orm/tree/2.16.x
|
||||
[2.16 coverage image]: https://codecov.io/gh/doctrine/orm/branch/2.16.x/graph/badge.svg
|
||||
[2.16 coverage]: https://codecov.io/gh/doctrine/orm/branch/2.16.x
|
||||
[2.15 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=2.15.x
|
||||
[2.15]: https://github.com/doctrine/orm/tree/2.15.x
|
||||
[2.15 coverage image]: https://codecov.io/gh/doctrine/orm/branch/2.15.x/graph/badge.svg
|
||||
[2.15 coverage]: https://codecov.io/gh/doctrine/orm/branch/2.15.x
|
||||
[2.18 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=2.18.x
|
||||
[2.18]: https://github.com/doctrine/orm/tree/2.18.x
|
||||
[2.18 coverage image]: https://codecov.io/gh/doctrine/orm/branch/2.18.x/graph/badge.svg
|
||||
[2.18 coverage]: https://codecov.io/gh/doctrine/orm/branch/2.18.x
|
||||
[2.17 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=2.17.x
|
||||
[2.17]: https://github.com/doctrine/orm/tree/2.17.x
|
||||
[2.17 coverage image]: https://codecov.io/gh/doctrine/orm/branch/2.17.x/graph/badge.svg
|
||||
[2.17 coverage]: https://codecov.io/gh/doctrine/orm/branch/2.17.x
|
||||
|
||||
@@ -13,6 +13,5 @@ understand the assumptions we make.
|
||||
- [DBAL Security Page](https://www.doctrine-project.org/projects/doctrine-dbal/en/stable/reference/security.html)
|
||||
- [ORM Security Page](https://www.doctrine-project.org/projects/doctrine-orm/en/stable/reference/security.html)
|
||||
|
||||
If you find a Security bug in Doctrine, please report it on Jira and change the
|
||||
Security Level to "Security Issues". It will be visible to Doctrine Core
|
||||
developers and you only.
|
||||
If you find a Security bug in Doctrine, please follow our
|
||||
[Security reporting guidelines](https://www.doctrine-project.org/policies/security.html#reporting).
|
||||
|
||||
@@ -36,71 +36,50 @@ Our entities look like:
|
||||
namespace Bank\Entities;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* @ORM\Entity
|
||||
*/
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
#[ORM\Entity]
|
||||
class Account
|
||||
{
|
||||
/**
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue
|
||||
* @ORM\Column(type="integer")
|
||||
*/
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="string", unique=true)
|
||||
*/
|
||||
private string $no;
|
||||
|
||||
/**
|
||||
* @ORM\OneToMany(targetEntity="Entry", mappedBy="account", cascade={"persist"})
|
||||
*/
|
||||
private array $entries;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="integer")
|
||||
*/
|
||||
private int $maxCredit = 0;
|
||||
|
||||
public function __construct(string $no, int $maxCredit = 0)
|
||||
{
|
||||
$this->no = $no;
|
||||
$this->maxCredit = $maxCredit;
|
||||
$this->entries = new \Doctrine\Common\Collections\ArrayCollection();
|
||||
|
||||
#[ORM\OneToMany(targetEntity: Entry::class, mappedBy: 'account', cascade: ['persist'])]
|
||||
private Collection $entries;
|
||||
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(type: 'string', unique: true)]
|
||||
private string $no,
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $maxCredit = 0,
|
||||
) {
|
||||
$this->entries = new ArrayCollection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @ORM\Entity
|
||||
*/
|
||||
|
||||
#[ORM\Entity]
|
||||
class Entry
|
||||
{
|
||||
/**
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue
|
||||
* @ORM\Column(type="integer")
|
||||
*/
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id;
|
||||
|
||||
/**
|
||||
* @ORM\ManyToOne(targetEntity="Account", inversedBy="entries")
|
||||
*/
|
||||
private Account $account;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="integer")
|
||||
*/
|
||||
private int $amount;
|
||||
|
||||
public function __construct(Account $account, int $amount)
|
||||
{
|
||||
$this->account = $account;
|
||||
$this->amount = $amount;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\ManyToOne(targetEntity: Account::class, inversedBy: 'entries')]
|
||||
private Account $account,
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $amount,
|
||||
) {
|
||||
// more stuff here, from/to whom, stated reason, execution date and such
|
||||
}
|
||||
|
||||
|
||||
public function getAmount(): Amount
|
||||
{
|
||||
return $this->amount;
|
||||
@@ -193,9 +172,8 @@ relation with this method:
|
||||
public function addEntry(int $amount): void
|
||||
{
|
||||
$this->assertAcceptEntryAllowed($amount);
|
||||
|
||||
$e = new Entry($this, $amount);
|
||||
$this->entries[] = $e;
|
||||
|
||||
$this->entries[] = new Entry($this, $amount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,18 +191,18 @@ Now look at the following test-code for our entities:
|
||||
{
|
||||
$account = new Account("123456", maxCredit: 200);
|
||||
$this->assertEquals(0, $account->getBalance());
|
||||
|
||||
|
||||
$account->addEntry(500);
|
||||
$this->assertEquals(500, $account->getBalance());
|
||||
|
||||
|
||||
$account->addEntry(-700);
|
||||
$this->assertEquals(-200, $account->getBalance());
|
||||
}
|
||||
|
||||
|
||||
public function testExceedMaxLimit()
|
||||
{
|
||||
$account = new Account("123456", maxCredit: 200);
|
||||
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$account->addEntry(-1000);
|
||||
}
|
||||
@@ -285,22 +263,19 @@ entries collection) we want to add an aggregate field called
|
||||
<?php
|
||||
class Account
|
||||
{
|
||||
/**
|
||||
* @ORM\Column(type="integer")
|
||||
*/
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $balance = 0;
|
||||
|
||||
|
||||
public function getBalance(): int
|
||||
{
|
||||
return $this->balance;
|
||||
}
|
||||
|
||||
|
||||
public function addEntry(int $amount): void
|
||||
{
|
||||
$this->assertAcceptEntryAllowed($amount);
|
||||
|
||||
$e = new Entry($this, $amount);
|
||||
$this->entries[] = $e;
|
||||
|
||||
$this->entries[] = new Entry($this, $amount);
|
||||
$this->balance += $amount;
|
||||
}
|
||||
}
|
||||
@@ -331,13 +306,13 @@ potentially lead to inconsistent state. See this example:
|
||||
// The Account $accId has a balance of 0 and a max credit limit of 200:
|
||||
// request 1 account
|
||||
$account1 = $em->find(Account::class, $accId);
|
||||
|
||||
|
||||
// request 2 account
|
||||
$account2 = $em->find(Account::class, $accId);
|
||||
|
||||
|
||||
$account1->addEntry(-200);
|
||||
$account2->addEntry(-200);
|
||||
|
||||
|
||||
// now request 1 and 2 both flush the changes.
|
||||
|
||||
The aggregate field ``Account::$balance`` is now -200, however the
|
||||
@@ -357,10 +332,8 @@ Optimistic locking is as easy as adding a version column:
|
||||
|
||||
class Account
|
||||
{
|
||||
/**
|
||||
* @ORM\Column(type="integer")
|
||||
* @ORM\Version
|
||||
*/
|
||||
#[ORM\Column(type: 'integer')]
|
||||
#[ORM\Version]
|
||||
private int $version;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,10 +47,8 @@ A Customer entity
|
||||
use Acme\CustomerModule\Entity\Customer as BaseCustomer;
|
||||
use Acme\InvoiceModule\Model\InvoiceSubjectInterface;
|
||||
|
||||
/**
|
||||
* @ORM\Entity
|
||||
* @ORM\Table(name="customer")
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'customer')]
|
||||
class Customer extends BaseCustomer implements InvoiceSubjectInterface
|
||||
{
|
||||
// In our example, any methods defined in the InvoiceSubjectInterface
|
||||
@@ -69,19 +67,12 @@ An Invoice entity
|
||||
use Doctrine\ORM\Mapping AS ORM;
|
||||
use Acme\InvoiceModule\Model\InvoiceSubjectInterface;
|
||||
|
||||
/**
|
||||
* Represents an Invoice.
|
||||
*
|
||||
* @ORM\Entity
|
||||
* @ORM\Table(name="invoice")
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'invoice')]
|
||||
class Invoice
|
||||
{
|
||||
/**
|
||||
* @ORM\ManyToOne(targetEntity="Acme\InvoiceModule\Model\InvoiceSubjectInterface")
|
||||
* @var InvoiceSubjectInterface
|
||||
*/
|
||||
protected $subject;
|
||||
#[ORM\ManyToOne(targetEntity: InvoiceSubjectInterface::class)]
|
||||
protected InvoiceSubjectInterface $subject;
|
||||
}
|
||||
|
||||
An InvoiceSubjectInterface
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
------------
|
||||
|
||||
|
||||
@@ -12,9 +12,8 @@ page only handles Security issues in the ORM.
|
||||
|
||||
- `DBAL Security Page <https://www.doctrine-project.org/projects/doctrine-dbal/en/current/reference/security.html>`
|
||||
|
||||
If you find a Security bug in Doctrine, please report it on Jira and change the
|
||||
Security Level to "Security Issues". It will be visible to Doctrine Core
|
||||
developers and you only.
|
||||
If you find a Security bug in Doctrine, please follow our
|
||||
`Security reporting guidelines <https://www.doctrine-project.org/policies/security.html#reporting>`_.
|
||||
|
||||
User input and Doctrine ORM
|
||||
---------------------------
|
||||
|
||||
@@ -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>
|
||||
|
||||
170
lib/Doctrine/ORM/Internal/StronglyConnectedComponents.php
Normal file
170
lib/Doctrine/ORM/Internal/StronglyConnectedComponents.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -57,7 +57,15 @@ final class DefaultTypedFieldMapper implements TypedFieldMapper
|
||||
$mapping['enumType'] = $type->getName();
|
||||
|
||||
$reflection = new ReflectionEnum($type->getName());
|
||||
$type = $reflection->getBackingType();
|
||||
if (! $reflection->isBacked()) {
|
||||
throw MappingException::backedEnumTypeRequired(
|
||||
$field->class,
|
||||
$mapping['fieldName'],
|
||||
$mapping['enumType']
|
||||
);
|
||||
}
|
||||
|
||||
$type = $reflection->getBackingType();
|
||||
|
||||
assert($type instanceof ReflectionNamedType);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Doctrine\ORM\Mapping\Driver;
|
||||
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
use Doctrine\Deprecations\Deprecation;
|
||||
use Doctrine\ORM\Mapping\Builder\EntityListenerBuilder;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Doctrine\ORM\Mapping\MappingException;
|
||||
@@ -56,15 +55,6 @@ class XmlDriver extends FileDriver
|
||||
);
|
||||
}
|
||||
|
||||
if (! $isXsdValidationEnabled) {
|
||||
Deprecation::trigger(
|
||||
'doctrine/orm',
|
||||
'https://github.com/doctrine/orm/pull/6728',
|
||||
'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(
|
||||
'XSD validation cannot be enabled because the DOM extension is missing.'
|
||||
|
||||
@@ -954,6 +954,16 @@ class MappingException extends ORMException
|
||||
return new self(sprintf('Enum types require PHP 8.1 in %s::$%s', $className, $fieldName));
|
||||
}
|
||||
|
||||
public static function backedEnumTypeRequired(string $className, string $fieldName, string $enumType): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Attempting to map a non-backed enum type %s in entity %s::$%s. Please use backed enums only',
|
||||
$enumType,
|
||||
$className,
|
||||
$fieldName
|
||||
));
|
||||
}
|
||||
|
||||
public static function nonEnumTypeMapped(string $className, string $fieldName, string $enumType): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
|
||||
@@ -257,7 +257,7 @@ class LimitSubqueryOutputWalker extends SqlWalker
|
||||
$innerSql
|
||||
);
|
||||
|
||||
// http://www.doctrine-project.org/jira/browse/DDC-1958
|
||||
// https://github.com/doctrine/orm/issues/2630
|
||||
$sql = $this->preserveSqlOrdering($sqlIdentifier, $innerSql, $sql, $orderByClause);
|
||||
|
||||
// Apply the limit and offset.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
22
tests/Doctrine/Tests/Models/Enums/FaultySwitch.php
Normal file
22
tests/Doctrine/Tests/Models/Enums/FaultySwitch.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Doctrine\Tests\Models\Enums;
|
||||
|
||||
use Doctrine\ORM\Mapping\Column;
|
||||
|
||||
class FaultySwitch
|
||||
{
|
||||
#[Column(type: 'string')]
|
||||
public string $value;
|
||||
|
||||
/**
|
||||
* The following line is ignored on psalm and phpstan so that we can test
|
||||
* that the mapping is throwing an exception when a non-backed enum is used.
|
||||
*
|
||||
* @psalm-suppress InvalidArgument
|
||||
*/
|
||||
#[Column(enumType: SwitchStatus::class)]
|
||||
public SwitchStatus $status;
|
||||
}
|
||||
11
tests/Doctrine/Tests/Models/Enums/SwitchStatus.php
Normal file
11
tests/Doctrine/Tests/Models/Enums/SwitchStatus.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Doctrine\Tests\Models\Enums;
|
||||
|
||||
enum SwitchStatus
|
||||
{
|
||||
case ON;
|
||||
case OFF;
|
||||
}
|
||||
@@ -18,6 +18,10 @@ class EagerFetchCollectionTest extends OrmFunctionalTestCase
|
||||
parent::setUp();
|
||||
|
||||
$this->createSchemaForModels(EagerFetchOwner::class, EagerFetchChild::class);
|
||||
|
||||
// Ensure tables are empty
|
||||
$this->_em->getRepository(EagerFetchChild::class)->createQueryBuilder('o')->delete()->getQuery()->execute();
|
||||
$this->_em->getRepository(EagerFetchOwner::class)->createQueryBuilder('o')->delete()->getQuery()->execute();
|
||||
}
|
||||
|
||||
public function testEagerFetchMode(): void
|
||||
@@ -84,6 +88,20 @@ 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();
|
||||
|
||||
// There is only a single record, but use a foreach to ensure the iterator is marked as finished and the table lock is released
|
||||
foreach ($iterable as $owner) {
|
||||
$this->assertCount(2, $owner->children);
|
||||
}
|
||||
}
|
||||
|
||||
protected function createOwnerWithChildren(int $children): EagerFetchOwner
|
||||
{
|
||||
$owner = new EagerFetchOwner();
|
||||
|
||||
165
tests/Doctrine/Tests/ORM/Functional/Ticket/GH10912Test.php
Normal file
165
tests/Doctrine/Tests/ORM/Functional/Ticket/GH10912Test.php
Normal 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;
|
||||
}
|
||||
167
tests/Doctrine/Tests/ORM/Functional/Ticket/GH10913Test.php
Normal file
167
tests/Doctrine/Tests/ORM/Functional/Ticket/GH10913Test.php
Normal 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;
|
||||
}
|
||||
122
tests/Doctrine/Tests/ORM/Functional/Ticket/GH10927Test.php
Normal file
122
tests/Doctrine/Tests/ORM/Functional/Ticket/GH10927Test.php
Normal 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
|
||||
{
|
||||
}
|
||||
146
tests/Doctrine/Tests/ORM/Functional/Ticket/GH11058Test.php
Normal file
146
tests/Doctrine/Tests/ORM/Functional/Ticket/GH11058Test.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
73
tests/Doctrine/Tests/ORM/Functional/Ticket/GH11135Test.php
Normal file
73
tests/Doctrine/Tests/ORM/Functional/Ticket/GH11135Test.php
Normal 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
|
||||
{
|
||||
}
|
||||
16
tests/Doctrine/Tests/ORM/Internal/Node.php
Normal file
16
tests/Doctrine/Tests/ORM/Internal/Node.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Doctrine\Tests\ORM\Internal;
|
||||
|
||||
class Node
|
||||
{
|
||||
/** @var string */
|
||||
public $name;
|
||||
|
||||
public function __construct(string $name)
|
||||
{
|
||||
$this->name = $name;
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -235,14 +287,3 @@ class TopologicalSortTest extends OrmTestCase
|
||||
}, array_values($this->topologicalSort->sort()));
|
||||
}
|
||||
}
|
||||
|
||||
class Node
|
||||
{
|
||||
/** @var string */
|
||||
public $name;
|
||||
|
||||
public function __construct(string $name)
|
||||
{
|
||||
$this->name = $name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Doctrine\Tests\ORM\Mapping;
|
||||
|
||||
use Doctrine\ORM\Mapping\DefaultTypedFieldMapper;
|
||||
use Doctrine\ORM\Mapping\MappingException;
|
||||
use Doctrine\Tests\Models\Enums\FaultySwitch;
|
||||
use Doctrine\Tests\OrmTestCase;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* @requires PHP >= 8.1
|
||||
*/
|
||||
class TypedEnumFieldMapperTest extends OrmTestCase
|
||||
{
|
||||
private static function defaultTypedFieldMapper(): DefaultTypedFieldMapper
|
||||
{
|
||||
return new DefaultTypedFieldMapper();
|
||||
}
|
||||
|
||||
public function testNotBackedEnumThrows(): void
|
||||
{
|
||||
$reflectionClass = new ReflectionClass(FaultySwitch::class);
|
||||
|
||||
$this->expectException(MappingException::class);
|
||||
$this->expectExceptionMessage(
|
||||
'Attempting to map a non-backed enum type Doctrine\Tests\Models\Enums\SwitchStatus in entity Doctrine\Tests\Models\Enums\FaultySwitch::$status. Please use backed enums only'
|
||||
);
|
||||
|
||||
self::defaultTypedFieldMapper()->validateAndComplete(['fieldName' => 'status'], $reflectionClass->getProperty('status'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user