diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index 47453fdae52..c6c3cb752aa 100644
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -190,7 +190,7 @@ jobs:
- "default"
- "3@dev"
mariadb-version:
- - "10.9"
+ - "11.4"
extension:
- "mysqli"
- "pdo_mysql"
@@ -204,11 +204,11 @@ jobs:
mariadb:
image: "mariadb:${{ matrix.mariadb-version }}"
env:
- MYSQL_ALLOW_EMPTY_PASSWORD: yes
- MYSQL_DATABASE: "doctrine_tests"
+ MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: yes
+ MARIADB_DATABASE: "doctrine_tests"
options: >-
- --health-cmd "mysqladmin ping --silent"
+ --health-cmd "healthcheck.sh --connect --innodb_initialized"
ports:
- "3306:3306"
diff --git a/.github/workflows/phpbench.yml b/.github/workflows/phpbench.yml
index 1e7ad8c10d1..b223e635930 100644
--- a/.github/workflows/phpbench.yml
+++ b/.github/workflows/phpbench.yml
@@ -47,15 +47,8 @@ jobs:
coverage: "pcov"
ini-values: "zend.assertions=1, apc.enable_cli=1"
- - name: "Cache dependencies installed with composer"
- uses: "actions/cache@v3"
- with:
- path: "~/.composer/cache"
- key: "php-${{ matrix.php-version }}-composer-locked-${{ hashFiles('composer.lock') }}"
- restore-keys: "php-${{ matrix.php-version }}-composer-locked-"
-
- - name: "Install dependencies with composer"
- run: "composer update --no-interaction --no-progress"
+ - name: "Install dependencies with Composer"
+ uses: "ramsey/composer-install@v3"
- name: "Run PHPBench"
run: "vendor/bin/phpbench run --report=default"
diff --git a/docs/en/reference/transactions-and-concurrency.rst b/docs/en/reference/transactions-and-concurrency.rst
index 9e477474280..afcbb216bce 100644
--- a/docs/en/reference/transactions-and-concurrency.rst
+++ b/docs/en/reference/transactions-and-concurrency.rst
@@ -88,7 +88,7 @@ requirement.
A more convenient alternative for explicit transaction demarcation is the use
of provided control abstractions in the form of
-``Connection#transactional($func)`` and ``EntityManager#transactional($func)``.
+``Connection#transactional($func)`` and ``EntityManager#wrapInTransaction($func)``.
When used, these control abstractions ensure that you never forget to rollback
the transaction, in addition to the obvious code reduction. An example that is
functionally equivalent to the previously shown code looks as follows:
@@ -96,21 +96,23 @@ functionally equivalent to the previously shown code looks as follows:
.. code-block:: php
transactional(function($conn) {
+ // ... do some work
+ $user = new User;
+ $user->setName('George');
+ });
+
+ // transactional with EntityManager instance
// $em instanceof EntityManager
- $em->transactional(function($em) {
+ $em->wrapInTransaction(function($em) {
// ... do some work
$user = new User;
$user->setName('George');
$em->persist($user);
});
-.. warning::
-
- For historical reasons, ``EntityManager#transactional($func)`` will return
- ``true`` whenever the return value of ``$func`` is loosely false.
- Some examples of this include ``array()``, ``"0"``, ``""``, ``0``, and
- ``null``.
-
The difference between ``Connection#transactional($func)`` and
``EntityManager#transactional($func)`` is that the latter
abstraction flushes the ``EntityManager`` prior to transaction
diff --git a/docs/en/tutorials/composite-primary-keys.rst b/docs/en/tutorials/composite-primary-keys.rst
index 456adeaf5de..386f8f140c0 100644
--- a/docs/en/tutorials/composite-primary-keys.rst
+++ b/docs/en/tutorials/composite-primary-keys.rst
@@ -188,7 +188,7 @@ We keep up the example of an Article with arbitrary attributes, the mapping look
#[OneToMany(targetEntity: ArticleAttribute::class, mappedBy: 'article', cascade: ['ALL'], indexBy: 'attribute')]
private Collection $attributes;
- public function addAttribute(string $name, ArticleAttribute $value): void
+ public function addAttribute(string $name, string $value): void
{
$this->attributes[$name] = new ArticleAttribute($name, $value, $this);
}
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 7858eafcbae..2be2ff0b8c3 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -1494,7 +1494,9 @@
-
+
diff --git a/src/Internal/Hydration/ObjectHydrator.php b/src/Internal/Hydration/ObjectHydrator.php
index a5d97d30966..c01496a5ca8 100644
--- a/src/Internal/Hydration/ObjectHydrator.php
+++ b/src/Internal/Hydration/ObjectHydrator.php
@@ -367,11 +367,15 @@ protected function hydrateRowData(array $row, array &$result)
$parentObject = $this->resultPointers[$parentAlias];
} else {
// Parent object of relation not found, mark as not-fetched again
- $element = $this->getEntity($data, $dqlAlias);
+ if (isset($nonemptyComponents[$dqlAlias])) {
+ $element = $this->getEntity($data, $dqlAlias);
- // Update result pointer and provide initial fetch data for parent
- $this->resultPointers[$dqlAlias] = $element;
- $rowData['data'][$parentAlias][$relationField] = $element;
+ // Update result pointer and provide initial fetch data for parent
+ $this->resultPointers[$dqlAlias] = $element;
+ $rowData['data'][$parentAlias][$relationField] = $element;
+ } else {
+ $element = null;
+ }
// Mark as not-fetched again
unset($this->_hints['fetched'][$parentAlias][$relationField]);
diff --git a/src/Persisters/Collection/OneToManyPersister.php b/src/Persisters/Collection/OneToManyPersister.php
index f39415fc0c9..6769acca909 100644
--- a/src/Persisters/Collection/OneToManyPersister.php
+++ b/src/Persisters/Collection/OneToManyPersister.php
@@ -8,6 +8,8 @@
use Doctrine\Common\Collections\Criteria;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\DBAL\Types\Type;
+use Doctrine\ORM\EntityNotFoundException;
+use Doctrine\ORM\Mapping\MappingException;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\Utility\PersisterHelper;
@@ -166,7 +168,11 @@ public function loadCriteria(PersistentCollection $collection, Criteria $criteri
throw new BadMethodCallException('Filtering a collection by Criteria is not supported by this CollectionPersister.');
}
- /** @throws DBALException */
+ /**
+ * @throws DBALException
+ * @throws EntityNotFoundException
+ * @throws MappingException
+ */
private function deleteEntityCollection(PersistentCollection $collection): int
{
$mapping = $collection->getMapping();
@@ -186,6 +192,13 @@ private function deleteEntityCollection(PersistentCollection $collection): int
$statement = 'DELETE FROM ' . $this->quoteStrategy->getTableName($targetClass, $this->platform)
. ' WHERE ' . implode(' = ? AND ', $columns) . ' = ?';
+ if ($targetClass->isInheritanceTypeSingleTable()) {
+ $discriminatorColumn = $targetClass->getDiscriminatorColumn();
+ $statement .= ' AND ' . $discriminatorColumn['name'] . ' = ?';
+ $parameters[] = $targetClass->discriminatorValue;
+ $types[] = $discriminatorColumn['type'];
+ }
+
$numAffected = $this->conn->executeStatement($statement, $parameters, $types);
assert(is_int($numAffected));
diff --git a/src/Proxy/ProxyFactory.php b/src/Proxy/ProxyFactory.php
index 5b2d2eca0c9..dc8a72bfcea 100644
--- a/src/Proxy/ProxyFactory.php
+++ b/src/Proxy/ProxyFactory.php
@@ -354,15 +354,14 @@ private function createInitializer(ClassMetadata $classMetadata, EntityPersister
/**
* Creates a closure capable of initializing a proxy
*
- * @return Closure(InternalProxy, InternalProxy):void
+ * @return Closure(InternalProxy, array):void
*
* @throws EntityNotFoundException
*/
private function createLazyInitializer(ClassMetadata $classMetadata, EntityPersister $entityPersister, IdentifierFlattener $identifierFlattener): Closure
{
- return static function (InternalProxy $proxy) use ($entityPersister, $classMetadata, $identifierFlattener): void {
- $identifier = $classMetadata->getIdentifierValues($proxy);
- $original = $entityPersister->loadById($identifier);
+ return static function (InternalProxy $proxy, array $identifier) use ($entityPersister, $classMetadata, $identifierFlattener): void {
+ $original = $entityPersister->loadById($identifier);
if ($original === null) {
throw EntityNotFoundException::fromClassNameAndIdentifier(
@@ -378,7 +377,7 @@ private function createLazyInitializer(ClassMetadata $classMetadata, EntityPersi
$class = $entityPersister->getClassMetadata();
foreach ($class->getReflectionProperties() as $property) {
- if (! $class->hasField($property->name) && ! $class->hasAssociation($property->name)) {
+ if (isset($identifier[$property->name]) || ! $class->hasField($property->name) && ! $class->hasAssociation($property->name)) {
continue;
}
@@ -468,7 +467,9 @@ private function getProxyFactory(string $className): Closure
$identifierFields = array_intersect_key($class->getReflectionProperties(), $identifiers);
$proxyFactory = Closure::bind(static function (array $identifier) use ($initializer, $skippedProperties, $identifierFields, $className): InternalProxy {
- $proxy = self::createLazyGhost($initializer, $skippedProperties);
+ $proxy = self::createLazyGhost(static function (InternalProxy $object) use ($initializer, $identifier): void {
+ $initializer($object, $identifier);
+ }, $skippedProperties);
foreach ($identifierFields as $idField => $reflector) {
if (! isset($identifier[$idField])) {
diff --git a/src/Query/Parser.php b/src/Query/Parser.php
index eb7da7337ac..a76cd4e45a6 100644
--- a/src/Query/Parser.php
+++ b/src/Query/Parser.php
@@ -2926,7 +2926,10 @@ public function ArithmeticPrimary()
return new AST\ParenthesisExpression($expr);
}
- assert($this->lexer->lookahead !== null);
+ if ($this->lexer->lookahead === null) {
+ $this->syntaxError('ArithmeticPrimary');
+ }
+
switch ($this->lexer->lookahead->type) {
case TokenType::T_COALESCE:
case TokenType::T_NULLIF:
diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php
index 4948be46536..4c25fb63a68 100644
--- a/src/Query/SqlWalker.php
+++ b/src/Query/SqlWalker.php
@@ -1062,7 +1062,9 @@ public function walkJoinAssociationDeclaration($joinAssociationDeclaration, $joi
}
}
- if ($relation['fetch'] === ClassMetadata::FETCH_EAGER && $condExpr !== null) {
+ $fetchMode = $this->query->getHint('fetchMode')[$assoc['sourceEntity']][$assoc['fieldName']] ?? $relation['fetch'];
+
+ if ($fetchMode === ClassMetadata::FETCH_EAGER && $condExpr !== null) {
throw QueryException::eagerFetchJoinWithNotAllowed($assoc['sourceEntity'], $assoc['fieldName']);
}
diff --git a/tests/Tests/Models/ECommerce/ECommerceProduct2.php b/tests/Tests/Models/ECommerce/ECommerceProduct2.php
new file mode 100644
index 00000000000..89f37417d2b
--- /dev/null
+++ b/tests/Tests/Models/ECommerce/ECommerceProduct2.php
@@ -0,0 +1,52 @@
+id;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function __clone()
+ {
+ $this->id = null;
+ $this->name = 'Clone of ' . $this->name;
+ }
+}
diff --git a/tests/Tests/ORM/Functional/EagerFetchCollectionTest.php b/tests/Tests/ORM/Functional/EagerFetchCollectionTest.php
index ff0eab56d63..88397c6a12f 100644
--- a/tests/Tests/ORM/Functional/EagerFetchCollectionTest.php
+++ b/tests/Tests/ORM/Functional/EagerFetchCollectionTest.php
@@ -88,6 +88,14 @@ public function testSubselectFetchJoinWithNotAllowed(): void
$query->getResult();
}
+ public function testSubselectFetchJoinWithAllowedWhenOverriddenNotEager(): void
+ {
+ $query = $this->_em->createQuery('SELECT o, c FROM ' . EagerFetchOwner::class . ' o JOIN o.children c WITH c.id = 1');
+ $query->setFetchMode(EagerFetchChild::class, 'owner', ORM\ClassMetadata::FETCH_LAZY);
+
+ $this->assertIsString($query->getSql());
+ }
+
public function testEagerFetchWithIterable(): void
{
$this->createOwnerWithChildren(2);
diff --git a/tests/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php b/tests/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php
index 01f82c8de7d..1cd05c3fc5a 100644
--- a/tests/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php
+++ b/tests/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php
@@ -58,7 +58,7 @@ protected function setUp(): void
public function testPersistUpdate(): void
{
// Considering case (a)
- $proxy = $this->_em->getProxyFactory()->getProxy(CmsUser::class, ['id' => 123]);
+ $proxy = $this->_em->getProxyFactory()->getProxy(CmsUser::class, ['id' => $this->user->getId()]);
$proxy->id = null;
$proxy->username = 'ocra';
diff --git a/tests/Tests/ORM/Functional/ReferenceProxyTest.php b/tests/Tests/ORM/Functional/ReferenceProxyTest.php
index 88c14253e20..bb4a2cfb36c 100644
--- a/tests/Tests/ORM/Functional/ReferenceProxyTest.php
+++ b/tests/Tests/ORM/Functional/ReferenceProxyTest.php
@@ -9,6 +9,7 @@
use Doctrine\ORM\Proxy\InternalProxy;
use Doctrine\Tests\Models\Company\CompanyAuction;
use Doctrine\Tests\Models\ECommerce\ECommerceProduct;
+use Doctrine\Tests\Models\ECommerce\ECommerceProduct2;
use Doctrine\Tests\Models\ECommerce\ECommerceShipping;
use Doctrine\Tests\OrmFunctionalTestCase;
@@ -112,6 +113,24 @@ public function testCloneProxy(): void
self::assertFalse($entity->isCloned);
}
+ public function testCloneProxyWithResetId(): void
+ {
+ $id = $this->createProduct();
+
+ $entity = $this->_em->getReference(ECommerceProduct2::class, $id);
+ assert($entity instanceof ECommerceProduct2);
+
+ $clone = clone $entity;
+ assert($clone instanceof ECommerceProduct2);
+
+ self::assertEquals($id, $entity->getId());
+ self::assertEquals('Doctrine Cookbook', $entity->getName());
+
+ self::assertFalse($this->_em->contains($clone));
+ self::assertNull($clone->getId());
+ self::assertEquals('Clone of Doctrine Cookbook', $clone->getName());
+ }
+
/** @group DDC-733 */
public function testInitializeProxy(): void
{
diff --git a/tests/Tests/ORM/Functional/Ticket/GH10889Test.php b/tests/Tests/ORM/Functional/Ticket/GH10889Test.php
new file mode 100644
index 00000000000..451fc887d20
--- /dev/null
+++ b/tests/Tests/ORM/Functional/Ticket/GH10889Test.php
@@ -0,0 +1,107 @@
+createSchemaForModels(
+ GH10889Person::class,
+ GH10889Company::class,
+ GH10889Resume::class
+ );
+ }
+
+ public function testIssue(): void
+ {
+ $person = new GH10889Person();
+ $resume = new GH10889Resume($person, null);
+
+ $this->_em->persist($person);
+ $this->_em->persist($resume);
+ $this->_em->flush();
+ $this->_em->clear();
+
+ /** @var list $resumes */
+ $resumes = $this->_em
+ ->getRepository(GH10889Resume::class)
+ ->createQueryBuilder('resume')
+ ->leftJoin('resume.currentCompany', 'company')->addSelect('company')
+ ->getQuery()
+ ->getResult();
+
+ $this->assertArrayHasKey(0, $resumes);
+ $this->assertEquals(1, $resumes[0]->person->id);
+ $this->assertNull($resumes[0]->currentCompany);
+ }
+}
+
+/**
+ * @ORM\Entity
+ */
+class GH10889Person
+{
+ /**
+ * @ORM\Id
+ * @ORM\Column(type="integer")
+ * @ORM\GeneratedValue
+ *
+ * @var int
+ */
+ public $id;
+}
+
+/**
+ * @ORM\Entity
+ */
+class GH10889Company
+{
+ /**
+ * @ORM\Id
+ * @ORM\Column(type="integer")
+ * @ORM\GeneratedValue
+ *
+ * @var int
+ */
+ public $id;
+}
+
+/**
+ * @ORM\Entity
+ */
+class GH10889Resume
+{
+ /**
+ * @ORM\Id
+ * @ORM\OneToOne(targetEntity="GH10889Person")
+ *
+ * @var GH10889Person
+ */
+ public $person;
+
+ /**
+ * @ORM\ManyToOne(targetEntity="GH10889Company")
+ *
+ * @var GH10889Company|null
+ */
+ public $currentCompany;
+
+ public function __construct(GH10889Person $person, ?GH10889Company $currentCompany)
+ {
+ $this->person = $person;
+ $this->currentCompany = $currentCompany;
+ }
+}
diff --git a/tests/Tests/ORM/Functional/Ticket/GH11487Test.php b/tests/Tests/ORM/Functional/Ticket/GH11487Test.php
new file mode 100644
index 00000000000..ef036e48ada
--- /dev/null
+++ b/tests/Tests/ORM/Functional/Ticket/GH11487Test.php
@@ -0,0 +1,40 @@
+expectException(QueryException::class);
+ $this->expectExceptionMessage('Syntax Error');
+ $this->_em->createQuery('UPDATE Doctrine\Tests\ORM\Functional\Ticket\TaxType t SET t.default =')->execute();
+ }
+}
+
+/** @Entity */
+class TaxType
+{
+ /**
+ * @var int|null
+ * @Column(type="integer")
+ * @Id
+ * @GeneratedValue
+ */
+ public $id;
+
+ /**
+ * @var bool
+ * @Column(type="boolean")
+ */
+ public $default = false;
+}
diff --git a/tests/Tests/ORM/Functional/Ticket/GH11500Test.php b/tests/Tests/ORM/Functional/Ticket/GH11500Test.php
new file mode 100644
index 00000000000..4be3bf2b76e
--- /dev/null
+++ b/tests/Tests/ORM/Functional/Ticket/GH11500Test.php
@@ -0,0 +1,137 @@
+setUpEntitySchema([
+ GH11500AbstractTestEntity::class,
+ GH11500TestEntityOne::class,
+ GH11500TestEntityTwo::class,
+ GH11500TestEntityHolder::class,
+ ]);
+ }
+
+ /**
+ * @throws ORMException
+ */
+ public function testDeleteOneToManyCollectionWithSingleTableInheritance(): void
+ {
+ $testEntityOne = new GH11500TestEntityOne();
+ $testEntityTwo = new GH11500TestEntityTwo();
+ $testEntityHolder = new GH11500TestEntityHolder();
+
+ $testEntityOne->testEntityHolder = $testEntityHolder;
+ $testEntityHolder->testEntityOnes->add($testEntityOne);
+
+ $testEntityTwo->testEntityHolder = $testEntityHolder;
+ $testEntityHolder->testEntityTwos->add($testEntityTwo);
+
+ $em = $this->getEntityManager();
+ $em->persist($testEntityOne);
+ $em->persist($testEntityTwo);
+ $em->persist($testEntityHolder);
+ $em->flush();
+
+ $testEntityTwosBeforeRemovalOfTestEntityOnes = $testEntityHolder->testEntityTwos->toArray();
+
+ $testEntityHolder->testEntityOnes = new ArrayCollection();
+ $em->persist($testEntityHolder);
+ $em->flush();
+ $em->refresh($testEntityHolder);
+
+ static::assertEmpty($testEntityHolder->testEntityOnes->toArray(), 'All records should have been deleted');
+ static::assertEquals($testEntityTwosBeforeRemovalOfTestEntityOnes, $testEntityHolder->testEntityTwos->toArray(), 'Different Entity\'s records should not have been deleted');
+ }
+}
+
+
+
+/**
+ * @ORM\Entity
+ * @ORM\Table(name="one_to_many_single_table_inheritance_test_entities")
+ * @ORM\InheritanceType("SINGLE_TABLE")
+ * @ORM\DiscriminatorColumn(name="type", type="string")
+ * @ORM\DiscriminatorMap({"test_entity_one"="GH11500TestEntityOne", "test_entity_two"="GH11500TestEntityTwo"})
+ */
+class GH11500AbstractTestEntity
+{
+ /**
+ * @ORM\Id
+ * @ORM\Column(type="integer")
+ * @ORM\GeneratedValue
+ *
+ * @var int
+ */
+ public $id;
+}
+
+
+/** @ORM\Entity */
+class GH11500TestEntityOne extends GH11500AbstractTestEntity
+{
+ /**
+ * @ORM\ManyToOne(targetEntity="GH11500TestEntityHolder", inversedBy="testEntityOnes")
+ * @ORM\JoinColumn(name="test_entity_holder_id", referencedColumnName="id")
+ *
+ * @var GH11500TestEntityHolder
+ */
+ public $testEntityHolder;
+}
+
+/** @ORM\Entity */
+class GH11500TestEntityTwo extends GH11500AbstractTestEntity
+{
+ /**
+ * @ORM\ManyToOne(targetEntity="GH11500TestEntityHolder", inversedBy="testEntityTwos")
+ * @ORM\JoinColumn(name="test_entity_holder_id", referencedColumnName="id")
+ *
+ * @var GH11500TestEntityHolder
+ */
+ public $testEntityHolder;
+}
+
+/** @ORM\Entity */
+class GH11500TestEntityHolder
+{
+ /**
+ * @ORM\Id
+ * @ORM\Column(type="integer")
+ * @ORM\GeneratedValue
+ *
+ * @var int
+ */
+ public $id;
+
+ /**
+ * @ORM\OneToMany(targetEntity="GH11500TestEntityOne", mappedBy="testEntityHolder", orphanRemoval=true)
+ *
+ * @var Collection
+ */
+ public $testEntityOnes;
+
+ /**
+ * @ORM\OneToMany(targetEntity="GH11500TestEntityTwo", mappedBy="testEntityHolder", orphanRemoval=true)
+ *
+ * @var Collection
+ */
+ public $testEntityTwos;
+
+ public function __construct()
+ {
+ $this->testEntityOnes = new ArrayCollection();
+ $this->testEntityTwos = new ArrayCollection();
+ }
+}