Repository: doctrine/orm (php)

Issue:
getAssociationMappedByTargetField on the owning side of a relation fails in 3.0
### BC Break Report


|    Q        |   A
|------------ | ------
| BC Break    | yes
| Version     | 3.0.0

#### Summary

Calling `Doctrine\ORM\Mapping\ClassMetadata::getAssociationMappedByTargetField` on a relation which is not an inverse side of a relation does not work anymore in 3.0

#### Previous behavior

In 2.x, the method returned `null` for such cases.

#### Current behavior

this assertion fails: https://github.com/doctrine/orm/blob/9acc70d5b8be55a6ac33e5c7313a124fa90dbe21/src/Mapping/ClassMetadata.php#L2467

#### How to reproduce

I found this when investigating a support question in the Symfony Slack channel. This BC break affects ApiPlatform: https://github.com/api-platform/core/blob/8a35ee20c7aaf03b5a3d96baf920e2c99c5ae0fd/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmLinkFactory.php#L70

To reproduce it, call `getAssociationMappedByTargetField` for a field being the owning side of a relation.


Pull request:
Throw a full-fledged exception on invalid call
In 2.x, `getAssociationMappedByTargetField()` used to return `null` when called with the owning side of an association.
That was undocumented and wrong because the phpdoc advertises a string as a return type.

In #10723, I wrongly assumed that nobody would be calling this method with the owning side of an association.

Let us throw a full fledged exception and advertise the proper way of avoiding this situation.

Closes #11250

Patch:
diff --git a/UPGRADE.md b/UPGRADE.md
index e6a61238a..71c2fc9a7 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -1,5 +1,16 @@
 # Upgrade to 3.0
 
+## BC BREAK: Calling `ClassMetadata::getAssociationMappedByTargetField()` with the owning side of an association now throws an exception
+
+Previously, calling
+`Doctrine\ORM\Mapping\ClassMetadata::getAssociationMappedByTargetField()` with
+the owning side of an association returned `null`, which was undocumented, and
+wrong according to the phpdoc of the parent method.
+
+If you do not know whether you are on the owning or inverse side of an association,
+you can use  `Doctrine\ORM\Mapping\ClassMetadata::isAssociationInverseSide()`
+to find out.
+
 ## BC BREAK: `Doctrine\ORM\Proxy\Autoloader` no longer extends `Doctrine\Common\Proxy\Autoloader`
 
 Make sure to use the former when writing a type declaration or an `instanceof` check.
diff --git a/src/Mapping/ClassMetadata.php b/src/Mapping/ClassMetadata.php
index b3d7838cc..8cd276788 100644
--- a/src/Mapping/ClassMetadata.php
+++ b/src/Mapping/ClassMetadata.php
@@ -41,6 +41,7 @@ use function is_subclass_of;
 use function ltrim;
 use function method_exists;
 use function spl_object_id;
+use function sprintf;
 use function str_contains;
 use function str_replace;
 use function strtolower;
@@ -2457,9 +2458,20 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable
 
     public function getAssociationMappedByTargetField(string $assocName): string
     {
-        $assoc = $this->associationMappings[$assocName];
+        $assoc = $this->getAssociationMapping($assocName);
 
-        assert($assoc instanceof InverseSideMapping);
+        if (! $assoc instanceof InverseSideMapping) {
+            throw new LogicException(sprintf(
+                <<<'EXCEPTION'
+                Context: Calling %s() with "%s", which is the owning side of an association.
+                Problem: The owning side of an association has no "mappedBy" field.
+                Solution: Call %s::isAssociationInverseSide() to check first.
+                EXCEPTION,
+                __METHOD__,
+                $assocName,
+                self::class,
+            ));
+        }
 
         return $assoc->mappedBy;
     }
diff --git a/src/Mapping/ClassMetadataFactory.php b/src/Mapping/ClassMetadataFactory.php
index a3a470180..e18b2daa1 100644
--- a/src/Mapping/ClassMetadataFactory.php
+++ b/src/Mapping/ClassMetadataFactory.php
@@ -642,7 +642,7 @@ class ClassMetadataFactory extends AbstractClassMetadataFactory
                         configuration.
                         We currently recommend "SEQUENCE" for "%s", when using DBAL 3,
                         and "IDENTITY" when using DBAL 4,
-                        so you should probably use the following configuration before upgrading to DBAL 4,
+                        so you should use probably use the following configuration before upgrading to DBAL 4,
                         and remove it after deploying that upgrade:
 
                         $configuration->setIdentityGenerationPreferences([


Test patch:
diff --git a/tests/Tests/ORM/Mapping/ClassMetadataTest.php b/tests/Tests/ORM/Mapping/ClassMetadataTest.php
index edd059594..c83960daf 100644
--- a/tests/Tests/ORM/Mapping/ClassMetadataTest.php
+++ b/tests/Tests/ORM/Mapping/ClassMetadataTest.php
@@ -47,6 +47,7 @@ use Doctrine\Tests\Models\TypedProperties\UserTypedWithCustomTypedField;
 use Doctrine\Tests\ORM\Mapping\TypedFieldMapper\CustomIntAsStringTypedFieldMapper;
 use Doctrine\Tests\OrmTestCase;
 use DoctrineGlobalArticle;
+use LogicException;
 use PHPUnit\Framework\Attributes\Group as TestGroup;
 use ReflectionClass;
 use stdClass;
@@ -1054,6 +1055,21 @@ class ClassMetadataTest extends OrmTestCase
 
         $metadata->addLifecycleCallback('foo', 'bar');
     }
+
+    public function testItThrowsOnInvalidCallToGetAssociationMappedByTargetField(): void
+    {
+        $metadata = new ClassMetadata(self::class);
+        $metadata->mapOneToOne(['fieldName' => 'foo', 'targetEntity' => 'bar']);
+
+        $this->expectException(LogicException::class);
+        $this->expectExceptionMessage(<<<'EXCEPTION'
+            Context: Calling Doctrine\ORM\Mapping\ClassMetadata::getAssociationMappedByTargetField() with "foo", which is the owning side of an association.
+            Problem: The owning side of an association has no "mappedBy" field.
+            Solution: Call Doctrine\ORM\Mapping\ClassMetadata::isAssociationInverseSide() to check first.
+            EXCEPTION);
+
+        $metadata->getAssociationMappedByTargetField('foo');
+    }
 }
 
 #[MappedSuperclass]