From 4ca3bcfa04a4c7a8eaea1cc27aca7289b055ceb9 Mon Sep 17 00:00:00 2001 From: Matthew Curland Date: Fri, 16 Aug 2024 18:49:21 -0600 Subject: [PATCH] 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'] + ); + } +}