diff --git a/.doctrine-project.json b/.doctrine-project.json index 0eeb48f5899..f3a38fb4bdd 100644 --- a/.doctrine-project.json +++ b/.doctrine-project.json @@ -11,17 +11,23 @@ "slug": "latest", "upcoming": true }, + { + "name": "3.3", + "branchName": "3.3.x", + "slug": "3.3", + "upcoming": true + }, { "name": "3.2", "branchName": "3.2.x", "slug": "3.2", - "upcoming": true + "current": true }, { "name": "3.1", "branchName": "3.1.x", "slug": "3.1", - "current": true + "maintained": false }, { "name": "3.0", diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index f9b2de81e24..59723f577fc 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -182,7 +182,7 @@ jobs: - "default" - "4@dev" mariadb-version: - - "10.9" + - "11.4" extension: - "mysqli" - "pdo_mysql" @@ -191,11 +191,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/README.md b/README.md index 70dceea1faa..1df322cf7e8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -| [4.0.x][4.0] | [3.2.x][3.2] | [3.1.x][3.1] | [2.20.x][2.20] | [2.19.x][2.19] | +| [4.0.x][4.0] | [3.3.x][3.3] | [3.2.x][3.2] | [2.20.x][2.20] | [2.19.x][2.19] | |:------------------------------------------------------:|:------------------------------------------------------:|:------------------------------------------------------:|:--------------------------------------------------------:|:--------------------------------------------------------:| -| [![Build status][4.0 image]][4.0] | [![Build status][3.2 image]][3.2] | [![Build status][3.1 image]][3.1] | [![Build status][2.20 image]][2.20] | [![Build status][2.19 image]][2.19] | -| [![Coverage Status][4.0 coverage image]][4.0 coverage] | [![Coverage Status][3.2 coverage image]][3.2 coverage] | [![Coverage Status][3.1 coverage image]][3.1 coverage] | [![Coverage Status][2.20 coverage image]][2.20 coverage] | [![Coverage Status][2.19 coverage image]][2.19 coverage] | +| [![Build status][4.0 image]][4.0] | [![Build status][3.3 image]][3.3] | [![Build status][3.2 image]][3.2] | [![Build status][2.20 image]][2.20] | [![Build status][2.19 image]][2.19] | +| [![Coverage Status][4.0 coverage image]][4.0 coverage] | [![Coverage Status][3.3 coverage image]][3.3 coverage] | [![Coverage Status][3.2 coverage image]][3.2 coverage] | [![Coverage Status][2.20 coverage image]][2.20 coverage] | [![Coverage Status][2.19 coverage image]][2.19 coverage] | [

πŸ‡ΊπŸ‡¦ UKRAINE NEEDS YOUR HELP NOW!

](https://www.doctrine-project.org/stop-war.html) @@ -22,14 +22,14 @@ without requiring unnecessary code duplication. [4.0]: https://github.com/doctrine/orm/tree/4.0.x [4.0 coverage image]: https://codecov.io/gh/doctrine/orm/branch/4.0.x/graph/badge.svg [4.0 coverage]: https://codecov.io/gh/doctrine/orm/branch/4.0.x + [3.3 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=3.3.x + [3.3]: https://github.com/doctrine/orm/tree/3.3.x + [3.3 coverage image]: https://codecov.io/gh/doctrine/orm/branch/3.3.x/graph/badge.svg + [3.3 coverage]: https://codecov.io/gh/doctrine/orm/branch/3.3.x [3.2 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=3.2.x [3.2]: https://github.com/doctrine/orm/tree/3.2.x [3.2 coverage image]: https://codecov.io/gh/doctrine/orm/branch/3.2.x/graph/badge.svg [3.2 coverage]: https://codecov.io/gh/doctrine/orm/branch/3.2.x - [3.1 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=3.1.x - [3.1]: https://github.com/doctrine/orm/tree/3.1.x - [3.1 coverage image]: https://codecov.io/gh/doctrine/orm/branch/3.1.x/graph/badge.svg - [3.1 coverage]: https://codecov.io/gh/doctrine/orm/branch/3.1.x [2.20 image]: https://github.com/doctrine/orm/actions/workflows/continuous-integration.yml/badge.svg?branch=2.20.x [2.20]: https://github.com/doctrine/orm/tree/2.20.x [2.20 coverage image]: https://codecov.io/gh/doctrine/orm/branch/2.20.x/graph/badge.svg diff --git a/docs/en/reference/transactions-and-concurrency.rst b/docs/en/reference/transactions-and-concurrency.rst index 8bbd661ba42..485b73e7251 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 12e078e0097..980feecbd76 100644 --- a/docs/en/tutorials/composite-primary-keys.rst +++ b/docs/en/tutorials/composite-primary-keys.rst @@ -145,7 +145,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 eceddd75e37..aa93c7b1f85 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -738,7 +738,9 @@ 4]]> - + diff --git a/src/Internal/Hydration/ObjectHydrator.php b/src/Internal/Hydration/ObjectHydrator.php index d24323d8689..d0fc101f215 100644 --- a/src/Internal/Hydration/ObjectHydrator.php +++ b/src/Internal/Hydration/ObjectHydrator.php @@ -356,11 +356,15 @@ protected function hydrateRowData(array $row, array &$result): void $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 c62ea565ed5..7354daa5627 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\Mapping\OneToManyAssociationMapping; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Utility\PersisterHelper; @@ -146,7 +148,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 = $this->getMapping($collection); @@ -166,6 +172,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 6184fa7811c..b5426a014e4 100644 --- a/src/Proxy/ProxyFactory.php +++ b/src/Proxy/ProxyFactory.php @@ -210,15 +210,14 @@ protected function skipClass(ClassMetadata $metadata): bool /** * 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( @@ -234,7 +233,7 @@ private function createLazyInitializer(ClassMetadata $classMetadata, EntityPersi $class = $entityPersister->getClassMetadata(); foreach ($class->getReflectionProperties() as $property) { - if (! $property || ! $class->hasField($property->getName()) && ! $class->hasAssociation($property->getName())) { + if (! $property || isset($identifier[$property->getName()]) || ! $class->hasField($property->getName()) && ! $class->hasAssociation($property->getName())) { continue; } @@ -283,7 +282,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 ade4bf347fe..e948f2c6b03 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -2563,7 +2563,10 @@ public function ArithmeticPrimary(): AST\Node|string 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 004d29e773c..c6f98c12d50 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -911,7 +911,9 @@ public function walkJoinAssociationDeclaration( } } - 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..1cbe939ef5b --- /dev/null +++ b/tests/Tests/Models/ECommerce/ECommerceProduct2.php @@ -0,0 +1,46 @@ +id; + } + + public function getName(): string|null + { + 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 88098c9c6da..b8d451097af 100644 --- a/tests/Tests/ORM/Functional/EagerFetchCollectionTest.php +++ b/tests/Tests/ORM/Functional/EagerFetchCollectionTest.php @@ -89,6 +89,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 b2b3306ea1b..0cc8776ba50 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 1a805d467c0..55f65956757 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; use PHPUnit\Framework\Attributes\Group; @@ -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..fe7d6e8c53c --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/GH10889Test.php @@ -0,0 +1,79 @@ +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] + #[ORM\GeneratedValue] + public int|null $id = null; +} + +#[ORM\Entity] +class GH10889Company +{ + #[ORM\Id] + #[ORM\Column] + #[ORM\GeneratedValue] + public int|null $id = null; +} + +#[ORM\Entity] +class GH10889Resume +{ + public function __construct( + #[ORM\Id] + #[ORM\OneToOne] + public GH10889Person $person, + #[ORM\ManyToOne] + public GH10889Company|null $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..3e622611454 --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/GH11487Test.php @@ -0,0 +1,34 @@ +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 +{ + #[Column] + #[Id] + #[GeneratedValue] + public int|null $id = null; + + #[Column] + public bool $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..b2f8123fabd --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/GH11500Test.php @@ -0,0 +1,109 @@ +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] + #[ORM\GeneratedValue] + public int|null $id = null; +} + + +#[ORM\Entity] +class GH11500TestEntityOne extends GH11500AbstractTestEntity +{ + #[ORM\ManyToOne(inversedBy:'testEntityOnes')] + #[ORM\JoinColumn(name:'test_entity_holder_id', referencedColumnName:'id')] + public GH11500TestEntityHolder|null $testEntityHolder = null; +} + +#[ORM\Entity] +class GH11500TestEntityTwo extends GH11500AbstractTestEntity +{ + #[ORM\ManyToOne(inversedBy:'testEntityTwos')] + #[ORM\JoinColumn(name:'test_entity_holder_id', referencedColumnName:'id')] + public GH11500TestEntityHolder|null $testEntityHolder = null; +} + +#[ORM\Entity] +class GH11500TestEntityHolder +{ + #[ORM\Id] + #[ORM\Column] + #[ORM\GeneratedValue] + public int|null $id = null; + + #[ORM\OneToMany(targetEntity: 'GH11500TestEntityOne', mappedBy: 'testEntityHolder', orphanRemoval: true)] + public Collection $testEntityOnes; + + #[ORM\OneToMany(targetEntity: 'GH11500TestEntityTwo', mappedBy: 'testEntityHolder', orphanRemoval: true)] + public Collection $testEntityTwos; + + public function __construct() + { + $this->testEntityOnes = new ArrayCollection(); + $this->testEntityTwos = new ArrayCollection(); + } +}