diff --git a/docs/en/reference/working-with-objects.rst b/docs/en/reference/working-with-objects.rst index cc107e6e174..eded99a29ec 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 diff --git a/docs/en/tutorials/getting-started.rst b/docs/en/tutorials/getting-started.rst index 3550daecf78..b972c119c51 100644 --- a/docs/en/tutorials/getting-started.rst +++ b/docs/en/tutorials/getting-started.rst @@ -139,12 +139,12 @@ step: // Create a simple "default" Doctrine ORM configuration for Attributes $config = ORMSetup::createAttributeMetadataConfiguration( - paths: array(__DIR__."/src"), + paths: [__DIR__ . '/src'], isDevMode: true, ); // or if you prefer XML // $config = ORMSetup::createXMLMetadataConfiguration( - // paths: array(__DIR__."/config/xml"), + // paths: [__DIR__ . '/config/xml'], // isDevMode: true, //); diff --git a/src/NativeQuery.php b/src/NativeQuery.php index 6cee0e843fb..8f57be88def 100644 --- a/src/NativeQuery.php +++ b/src/NativeQuery.php @@ -40,7 +40,15 @@ protected function _doExecute(): Result|int $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/src/Persisters/Collection/OneToManyPersister.php b/src/Persisters/Collection/OneToManyPersister.php index 0727b1f8a7e..d96be8deea8 100644 --- a/src/Persisters/Collection/OneToManyPersister.php +++ b/src/Persisters/Collection/OneToManyPersister.php @@ -14,9 +14,12 @@ use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Utility\PersisterHelper; +use function array_fill; +use function array_keys; 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; @@ -174,9 +177,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/src/Persisters/Entity/BasicEntityPersister.php b/src/Persisters/Entity/BasicEntityPersister.php index 377e03ce274..abaf8f4c87b 100644 --- a/src/Persisters/Entity/BasicEntityPersister.php +++ b/src/Persisters/Entity/BasicEntityPersister.php @@ -792,17 +792,42 @@ public function loadOneToOneEntity(AssociationMapping $assoc, object $sourceEnti $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..2bb15b8cb29 --- /dev/null +++ b/tests/Tests/Models/OneToOneInverseSideWithAssociativeIdLoad/InverseSide.php @@ -0,0 +1,25 @@ +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'], + ); + } +} diff --git a/tests/Tests/ORM/Functional/Ticket/GH11501Test.php b/tests/Tests/ORM/Functional/Ticket/GH11501Test.php new file mode 100644 index 00000000000..ffecab8c0de --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/GH11501Test.php @@ -0,0 +1,105 @@ +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] + public int $id; + + #[ORM\ManyToOne(targetEntity: 'GH11501TestEntityHolder', inversedBy: 'testEntities')] + #[ORM\JoinColumn(name: 'test_entity_holder_id', referencedColumnName: 'id')] + public GH11501TestEntityHolder $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] + public int $id; + + #[ORM\OneToMany( + targetEntity: 'GH11501AbstractTestEntity', + mappedBy: 'testEntityHolder', + orphanRemoval: true, + )] + public Collection $testEntities; + + public function __construct() + { + $this->testEntities = new ArrayCollection(); + } +} 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()); + } +}