From 19129e9f8a012c0789d17ef72c263ea384bbc48b Mon Sep 17 00:00:00 2001 From: Konrad Abicht Date: Fri, 28 Jun 2024 08:02:28 +0200 Subject: [PATCH 1/7] working-with-objects.rst: added missing white space --- docs/en/reference/working-with-objects.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/en/reference/working-with-objects.rst b/docs/en/reference/working-with-objects.rst index d88b814e8c4..cc889ddde3f 100644 --- a/docs/en/reference/working-with-objects.rst +++ b/docs/en/reference/working-with-objects.rst @@ -338,10 +338,11 @@ Performance of different deletion strategies Deleting an object with all its associated objects can be achieved in multiple ways with very different performance impacts. -1. If an association is marked as ``CASCADE=REMOVE`` Doctrine ORM - will fetch this association. If its a Single association it will - pass this entity to - ``EntityManager#remove()``. If the association is a collection, Doctrine will loop over all its elements and pass them to``EntityManager#remove()``. +1. If an association is marked as ``CASCADE=REMOVE`` Doctrine ORM will + fetch this association. If it's a Single association it will pass + this entity to ``EntityManager#remove()``. If the association is a + collection, Doctrine will loop over all its elements and pass them to + ``EntityManager#remove()``. In both cases the cascade remove semantics are applied recursively. For large object graphs this removal strategy can be very costly. 2. Using a DQL ``DELETE`` statement allows you to delete multiple From 1fe1a6a048dd420d06704f72b296a463237d7603 Mon Sep 17 00:00:00 2001 From: Xesau Date: Mon, 1 Jul 2024 21:57:36 +0200 Subject: [PATCH 2/7] Fix incorrect exception message for ManyToOne attribute in embeddable class (#11536) When a ManyToOne attribute is encountered on an Embeddable class, the exception message reads "Attribute "Doctrine\ORM\Mapping\OneToMany" on embeddable [class] is not allowed.". This should be "Doctrine\ORM\Mapping\ManyToOne" on embeddable [class] is not allowed.". --- src/Mapping/Driver/AttributeDriver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mapping/Driver/AttributeDriver.php b/src/Mapping/Driver/AttributeDriver.php index 6fed1a24e67..9ba3481e3ba 100644 --- a/src/Mapping/Driver/AttributeDriver.php +++ b/src/Mapping/Driver/AttributeDriver.php @@ -390,7 +390,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata $metadata->mapOneToMany($mapping); } elseif ($manyToOneAttribute !== null) { if ($metadata->isEmbeddedClass) { - throw MappingException::invalidAttributeOnEmbeddable($metadata->name, Mapping\OneToMany::class); + throw MappingException::invalidAttributeOnEmbeddable($metadata->name, Mapping\ManyToOne::class); } $idAttribute = $this->reader->getPropertyAttribute($property, Mapping\Id::class); From 9bd51aaeb6d0f61f4d25ea838a951cb52db1e8b7 Mon Sep 17 00:00:00 2001 From: Christophe Coevoet Date: Wed, 3 Jul 2024 15:14:49 +0200 Subject: [PATCH 3/7] Fix the support for custom parameter types in native queries The Query class (used for DQL queries) takes care of using the value and type as is when a type was specified for a parameter instead of going through the default processing of values. The NativeQuery class was missing the equivalent check, making the custom type work only if the default processing of values does not convert the value to a different one. --- src/NativeQuery.php | 10 +++++- tests/Tests/ORM/Query/NativeQueryTest.php | 42 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/Tests/ORM/Query/NativeQueryTest.php diff --git a/src/NativeQuery.php b/src/NativeQuery.php index aa44539d544..782983d50ec 100644 --- a/src/NativeQuery.php +++ b/src/NativeQuery.php @@ -50,7 +50,15 @@ protected function _doExecute() $types = []; foreach ($this->getParameters() as $parameter) { - $name = $parameter->getName(); + $name = $parameter->getName(); + + if ($parameter->typeWasSpecified()) { + $parameters[$name] = $parameter->getValue(); + $types[$name] = $parameter->getType(); + + continue; + } + $value = $this->processParameterValue($parameter->getValue()); $type = $parameter->getValue() === $value ? $parameter->getType() diff --git a/tests/Tests/ORM/Query/NativeQueryTest.php b/tests/Tests/ORM/Query/NativeQueryTest.php new file mode 100644 index 00000000000..0e68389494e --- /dev/null +++ b/tests/Tests/ORM/Query/NativeQueryTest.php @@ -0,0 +1,42 @@ +entityManager = $this->getTestEntityManager(); + } + + public function testValuesAreNotBeingResolvedForSpecifiedParameterTypes(): void + { + $unitOfWork = $this->createMock(UnitOfWork::class); + + $this->entityManager->setUnitOfWork($unitOfWork); + + $unitOfWork + ->expects(self::never()) + ->method('getSingleIdentifierValue'); + + $rsm = new ResultSetMapping(); + + $query = $this->entityManager->createNativeQuery('SELECT d.* FROM date_time_model d WHERE d.datetime = :value', $rsm); + + $query->setParameter('value', new DateTime(), Types::DATETIME_MUTABLE); + + self::assertEmpty($query->getResult()); + } +} From 121158f92c9e5c5ed52accafeaaca8e3412cf9ec Mon Sep 17 00:00:00 2001 From: Kyron Taylor Date: Sat, 3 Aug 2024 16:49:18 +0100 Subject: [PATCH 4/7] GH11551 - fix OneToManyPersister::deleteEntityCollection when using single-inheritence entity parent as targetEntity. When using the parent entity for a single-inheritence table as the targetEntity for a property, the discriminator value should be all of the values in the discriminator map. OneToManyPersister::deleteEntityCollection has been amended to reflect this. --- .../Collection/OneToManyPersister.php | 12 +- .../ORM/Functional/Ticket/GH11501Test.php | 120 ++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 tests/Tests/ORM/Functional/Ticket/GH11501Test.php diff --git a/src/Persisters/Collection/OneToManyPersister.php b/src/Persisters/Collection/OneToManyPersister.php index 6769acca909..aed37556bc7 100644 --- a/src/Persisters/Collection/OneToManyPersister.php +++ b/src/Persisters/Collection/OneToManyPersister.php @@ -13,10 +13,13 @@ use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Utility\PersisterHelper; +use function array_fill; +use function array_keys; use function array_merge; use function array_reverse; use function array_values; use function assert; +use function count; use function implode; use function is_int; use function is_string; @@ -194,9 +197,12 @@ private function deleteEntityCollection(PersistentCollection $collection): int if ($targetClass->isInheritanceTypeSingleTable()) { $discriminatorColumn = $targetClass->getDiscriminatorColumn(); - $statement .= ' AND ' . $discriminatorColumn['name'] . ' = ?'; - $parameters[] = $targetClass->discriminatorValue; - $types[] = $discriminatorColumn['type']; + $discriminatorValues = $targetClass->discriminatorValue ? [$targetClass->discriminatorValue] : array_keys($targetClass->discriminatorMap); + $statement .= ' AND ' . $discriminatorColumn['name'] . ' IN (' . implode(', ', array_fill(0, count($discriminatorValues), '?')) . ')'; + foreach ($discriminatorValues as $discriminatorValue) { + $parameters[] = $discriminatorValue; + $types[] = $discriminatorColumn['type']; + } } $numAffected = $this->conn->executeStatement($statement, $parameters, $types); diff --git a/tests/Tests/ORM/Functional/Ticket/GH11501Test.php b/tests/Tests/ORM/Functional/Ticket/GH11501Test.php new file mode 100644 index 00000000000..715137d43af --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/GH11501Test.php @@ -0,0 +1,120 @@ +setUpEntitySchema([ + GH11501AbstractTestEntity::class, + GH11501TestEntityOne::class, + GH11501TestEntityTwo::class, + GH11501TestEntityHolder::class, + ]); + } + + /** + * @throws ORMException + */ + public function testDeleteOneToManyCollectionWithSingleTableInheritance(): void + { + $testEntityOne = new GH11501TestEntityOne(); + $testEntityTwo = new GH11501TestEntityTwo(); + $testEntityHolder = new GH11501TestEntityHolder(); + + $testEntityOne->testEntityHolder = $testEntityHolder; + $testEntityHolder->testEntities->add($testEntityOne); + + $testEntityTwo->testEntityHolder = $testEntityHolder; + $testEntityHolder->testEntities->add($testEntityTwo); + + $em = $this->getEntityManager(); + $em->persist($testEntityOne); + $em->persist($testEntityTwo); + $em->persist($testEntityHolder); + $em->flush(); + + $testEntityHolder->testEntities = new ArrayCollection(); + $em->persist($testEntityHolder); + $em->flush(); + $em->refresh($testEntityHolder); + + static::assertEmpty($testEntityHolder->testEntities->toArray(), 'All records should have been deleted'); + } +} + + + +/** + * @ORM\Entity + * @ORM\Table(name="one_to_many_single_table_inheritance_test_entities_parent_join") + * @ORM\InheritanceType("SINGLE_TABLE") + * @ORM\DiscriminatorColumn(name="type", type="string") + * @ORM\DiscriminatorMap({"test_entity_one"="GH11501TestEntityOne", "test_entity_two"="GH11501TestEntityTwo"}) + */ +class GH11501AbstractTestEntity +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue + * + * @var int + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity="GH11501TestEntityHolder", inversedBy="testEntities") + * @ORM\JoinColumn(name="test_entity_holder_id", referencedColumnName="id") + * + * @var GH11501TestEntityHolder + */ + public $testEntityHolder; +} + + +/** @ORM\Entity */ +class GH11501TestEntityOne extends GH11501AbstractTestEntity +{ +} + +/** @ORM\Entity */ +class GH11501TestEntityTwo extends GH11501AbstractTestEntity +{ +} + +/** @ORM\Entity */ +class GH11501TestEntityHolder +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue + * + * @var int + */ + public $id; + + /** + * @ORM\OneToMany(targetEntity="GH11501AbstractTestEntity", mappedBy="testEntityHolder", orphanRemoval=true) + * + * @var Collection + */ + public $testEntities; + + public function __construct() + { + $this->testEntities = new ArrayCollection(); + } +} From 2707b09a07e00097bfef81f3ee35c6435586e0a6 Mon Sep 17 00:00:00 2001 From: gitbugr Date: Sat, 3 Aug 2024 21:38:49 +0100 Subject: [PATCH 5/7] fix spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Grégoire Paris --- src/Persisters/Collection/OneToManyPersister.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Persisters/Collection/OneToManyPersister.php b/src/Persisters/Collection/OneToManyPersister.php index aed37556bc7..1e032e99b49 100644 --- a/src/Persisters/Collection/OneToManyPersister.php +++ b/src/Persisters/Collection/OneToManyPersister.php @@ -201,7 +201,7 @@ private function deleteEntityCollection(PersistentCollection $collection): int $statement .= ' AND ' . $discriminatorColumn['name'] . ' IN (' . implode(', ', array_fill(0, count($discriminatorValues), '?')) . ')'; foreach ($discriminatorValues as $discriminatorValue) { $parameters[] = $discriminatorValue; - $types[] = $discriminatorColumn['type']; + $types[] = $discriminatorColumn['type']; } } From 3f550c19e3cbdb5d5a13be10182eb3d52d69b604 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Thu, 11 Jul 2024 14:41:28 +0200 Subject: [PATCH 6/7] DQL custom functions: document TypedExpression Partially related to https://github.com/doctrine/orm/issues/11537 Co-authored-by: Claudio Zizza <859964+SenseException@users.noreply.github.com> --- .../cookbook/dql-user-defined-functions.rst | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/en/cookbook/dql-user-defined-functions.rst b/docs/en/cookbook/dql-user-defined-functions.rst index b189ed59fcd..e1782e05669 100644 --- a/docs/en/cookbook/dql-user-defined-functions.rst +++ b/docs/en/cookbook/dql-user-defined-functions.rst @@ -232,6 +232,33 @@ vendors SQL parser to show us further errors in the parsing process, for example if the Unit would not be one of the supported values by MySql. +Typed functions +--------------- +By default, result of custom functions is fetched as-is from the database driver. +If you want to be sure that the type is always the same, then your custom function needs to +implement ``Doctrine\ORM\Query\AST\TypedExpression``. Then, the result is wired +through ``Doctrine\DBAL\Types\Type::convertToPhpValue()`` of the ``Type`` returned in ``getReturnType()``. + +.. code-block:: php + + Date: Fri, 16 Aug 2024 18:49:21 -0600 Subject: [PATCH 7/7] Original entity data resolves inverse 1-1 joins If the source entity for an inverse (non-owning) 1-1 relationship is identified by an association then the identifying association may not be set when an inverse one-to-one association is resolved. This means that no data is available in the entity to resolve the needed column value for the join query. The original entity data can be retrieved from the unit of work and is used as a fallback to populate the query condition. Fixes #11108 --- .../Entity/BasicEntityPersister.php | 39 +++++++++-- .../InverseSide.php | 34 ++++++++++ .../InverseSideIdTarget.php | 33 +++++++++ .../OwningSide.php | 37 ++++++++++ ...WithAssociativeIdLoadAfterDqlQueryTest.php | 68 +++++++++++++++++++ 5 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/InverseSide.php create mode 100644 tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/InverseSideIdTarget.php create mode 100644 tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/OwningSide.php create mode 100644 tests/Tests/ORM/Functional/OneToOneInverseSideWithAssociativeIdLoadAfterDqlQueryTest.php diff --git a/src/Persisters/Entity/BasicEntityPersister.php b/src/Persisters/Entity/BasicEntityPersister.php index 00fe7b03703..5ca00cb007e 100644 --- a/src/Persisters/Entity/BasicEntityPersister.php +++ b/src/Persisters/Entity/BasicEntityPersister.php @@ -832,17 +832,42 @@ public function loadOneToOneEntity(array $assoc, $sourceEntity, array $identifie $computedIdentifier = []; + /** @var array|null $sourceEntityData */ + $sourceEntityData = null; + // TRICKY: since the association is specular source and target are flipped foreach ($owningAssoc['targetToSourceKeyColumns'] as $sourceKeyColumn => $targetKeyColumn) { if (! isset($sourceClass->fieldNames[$sourceKeyColumn])) { - throw MappingException::joinColumnMustPointToMappedField( - $sourceClass->name, - $sourceKeyColumn - ); - } + // The likely case here is that the column is a join column + // in an association mapping. However, there is no guarantee + // at this point that a corresponding (generally identifying) + // association has been mapped in the source entity. To handle + // this case we directly reference the column-keyed data used + // to initialize the source entity before throwing an exception. + $resolvedSourceData = false; + if (! isset($sourceEntityData)) { + $sourceEntityData = $this->em->getUnitOfWork()->getOriginalEntityData($sourceEntity); + } + + if (isset($sourceEntityData[$sourceKeyColumn])) { + $dataValue = $sourceEntityData[$sourceKeyColumn]; + if ($dataValue !== null) { + $resolvedSourceData = true; + $computedIdentifier[$targetClass->getFieldForColumn($targetKeyColumn)] = + $dataValue; + } + } - $computedIdentifier[$targetClass->getFieldForColumn($targetKeyColumn)] = - $sourceClass->reflFields[$sourceClass->fieldNames[$sourceKeyColumn]]->getValue($sourceEntity); + if (! $resolvedSourceData) { + throw MappingException::joinColumnMustPointToMappedField( + $sourceClass->name, + $sourceKeyColumn + ); + } + } else { + $computedIdentifier[$targetClass->getFieldForColumn($targetKeyColumn)] = + $sourceClass->reflFields[$sourceClass->fieldNames[$sourceKeyColumn]]->getValue($sourceEntity); + } } $targetEntity = $this->load($computedIdentifier, null, $assoc); diff --git a/tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/InverseSide.php b/tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/InverseSide.php new file mode 100644 index 00000000000..0dcb9a93a1b --- /dev/null +++ b/tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/InverseSide.php @@ -0,0 +1,34 @@ +createSchemaForModels(OwningSide::class, InverseSideIdTarget::class, InverseSide::class); + } + + /** @group GH-11108 */ + public function testInverseSideWithAssociativeIdOneToOneLoadedAfterDqlQuery(): void + { + $owner = new OwningSide(); + $inverseId = new InverseSideIdTarget(); + $inverse = new InverseSide(); + + $owner->id = 'owner'; + $inverseId->id = 'inverseId'; + $inverseId->inverseSide = $inverse; + $inverse->associativeId = $inverseId; + $owner->inverse = $inverse; + $inverse->owning = $owner; + + $this->_em->persist($owner); + $this->_em->persist($inverseId); + $this->_em->persist($inverse); + $this->_em->flush(); + $this->_em->clear(); + + $fetchedInverse = $this + ->_em + ->createQueryBuilder() + ->select('inverse') + ->from(InverseSide::class, 'inverse') + ->andWhere('inverse.associativeId = :associativeId') + ->setParameter('associativeId', 'inverseId') + ->getQuery() + ->getSingleResult(); + assert($fetchedInverse instanceof InverseSide); + + self::assertInstanceOf(InverseSide::class, $fetchedInverse); + self::assertInstanceOf(InverseSideIdTarget::class, $fetchedInverse->associativeId); + self::assertInstanceOf(OwningSide::class, $fetchedInverse->owning); + + $this->assertSQLEquals( + 'select o0_.associativeid as associativeid_0 from one_to_one_inverse_side_assoc_id_load_inverse o0_ where o0_.associativeid = ?', + $this->getLastLoggedQuery(1)['sql'] + ); + + $this->assertSQLEquals( + 'select t0.id as id_1, t0.inverse as inverse_2 from one_to_one_inverse_side_assoc_id_load_owning t0 where t0.inverse = ?', + $this->getLastLoggedQuery()['sql'] + ); + } +}