Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to-many associations on mapped superclasses w/ ResolveTargetEntityListener #10473

Merged
merged 3 commits into from
Mar 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions docs/en/reference/inheritance-mapping.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,32 @@ is common to multiple entity classes.

Mapped superclasses, just as regular, non-mapped classes, can
appear in the middle of an otherwise mapped inheritance hierarchy
(through Single Table Inheritance or Class Table Inheritance).
(through Single Table Inheritance or Class Table Inheritance). They
are not query-able, and need not have an ``#[Id]`` property.

No database table will be created for a mapped superclass itself,
only for entity classes inheriting from it. Also, a mapped superclass
need not have an ``#[Id]`` property.
only for entity classes inheriting from it. That implies that a
mapped superclass cannot be the ``targetEntity`` in associations.

In other words, a mapped superclass can use unidirectional One-To-One
and Many-To-One associations where it is the owning side.
Many-To-Many associations are only possible if the mapped
superclass is only used in exactly one entity at the moment. For further
support of inheritance, the single or joined table inheritance features
have to be used.

.. note::

A mapped superclass cannot be an entity, it is not query-able and
persistent relationships defined by a mapped superclass must be
unidirectional (with an owning side only). This means that One-To-Many
associations are not possible on a mapped superclass at all.
Furthermore Many-To-Many associations are only possible if the
mapped superclass is only used in exactly one entity at the moment.
For further support of inheritance, the single or
joined table inheritance features have to be used.
One-To-Many associations are not generally possible on a mapped
superclass, since they require the "many" side to hold the foreign
key.

It is, however, possible to use the :doc:```ResolveTargetEntityListener`` <cookbook/resolve-target-entity-listener>`
to replace references to a mapped superclass with an entity class at runtime.
As long as there is only one entity subclass inheriting from the mapped
superclass and all references to the mapped superclass are resolved to that
entity class at runtime, the mapped superclass *can* use One-To-Many associations
and be named as the ``targetEntity`` on the owning sides.

.. note::

Expand Down
15 changes: 11 additions & 4 deletions lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ protected function validateRuntimeMetadata($class, $parent)

$class->validateIdentifier();
$class->validateAssociations();
$this->validateAssociationTargets($class);
$class->validateLifecycleCallbacks($this->getReflectionService());

// verify inheritance
Expand Down Expand Up @@ -303,6 +304,16 @@ protected function validateRuntimeMetadata($class, $parent)
}
}

private function validateAssociationTargets(ClassMetadata $class): void
{
foreach ($class->getAssociationMappings() as $mapping) {
$targetEntity = $mapping['targetEntity'];
if ($this->driver->isTransient($targetEntity) || $this->peekIfIsMappedSuperclass($targetEntity)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct, or am I missing a case where different drivers might be used for entities from different modules or similar?

There is just one ClassMetadataFactory for all entities (within the boundaries of a particular entity manager), and it has a single driver, right?

throw MappingException::associationTargetIsNotAnEntity($targetEntity, $class->name, $mapping['fieldName']);
}
}
}

/**
* {@inheritDoc}
*/
Expand Down Expand Up @@ -468,10 +479,6 @@ private function addInheritedRelations(ClassMetadata $subClass, ClassMetadata $p
// According to the definitions given in https://github.com/doctrine/orm/pull/10396/,
// this is the case <=> ! isset($mapping['inherited']).
if (! isset($mapping['inherited'])) {
if ($mapping['type'] & ClassMetadata::TO_MANY && ! $mapping['isOwningSide']) {
throw MappingException::illegalToManyAssociationOnMappedSuperclass($parentClass->name, $field);
}

$mapping['sourceEntity'] = $subClass->name;
}

Expand Down
9 changes: 9 additions & 0 deletions lib/Doctrine/ORM/Mapping/MappingException.php
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,15 @@ public static function invalidTargetEntityClass($targetEntity, $sourceEntity, $a
return new self('The target-entity ' . $targetEntity . " cannot be found in '" . $sourceEntity . '#' . $associationName . "'.");
}

/**
* @param class-string $targetEntity
* @param class-string $sourceEntity
*/
public static function associationTargetIsNotAnEntity(string $targetEntity, string $sourceEntity, string $associationName): self
{
return new self(sprintf('The target entity class %s specified for %s::$%s is not an entity class.', $targetEntity, $sourceEntity, $associationName));
}

/**
* @param string[] $cascades
* @param string $className
Expand Down
6 changes: 6 additions & 0 deletions tests/Doctrine/Tests/Models/Cache/Attraction.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping\Cache;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\DiscriminatorMap;
use Doctrine\ORM\Mapping\Entity;
Expand All @@ -29,6 +30,7 @@
* 3 = "Bar"
* })
*/
#[Entity]
abstract class Attraction
{
/**
Expand Down Expand Up @@ -109,4 +111,8 @@ public function addInfo(AttractionInfo $info): void
$this->infos->add($info);
}
}

public static function loadMetadata(ClassMetadata $metadata): void
{
}
}
6 changes: 6 additions & 0 deletions tests/Doctrine/Tests/Models/Cache/State.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping\Cache;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
Expand All @@ -21,6 +22,7 @@
* @Table("cache_state")
* @Cache("NONSTRICT_READ_WRITE")
*/
#[Entity]
class State
{
/**
Expand Down Expand Up @@ -105,4 +107,8 @@ public function addCity(City $city): void
{
$this->cities[] = $city;
}

public static function loadMetadata(ClassMetadata $metadata): void
{
}
}
6 changes: 6 additions & 0 deletions tests/Doctrine/Tests/Models/Cache/Travel.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping\Cache;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
Expand All @@ -23,6 +24,7 @@
* @Entity
* @Table("cache_travel")
*/
#[Entity]
class Travel
{
/**
Expand Down Expand Up @@ -104,4 +106,8 @@ public function getCreatedAt(): DateTime
{
return $this->createdAt;
}

public static function loadMetadata(ClassMetadata $metadata): void
{
}
}
5 changes: 5 additions & 0 deletions tests/Doctrine/Tests/Models/DDC3579/DDC3579Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
Expand Down Expand Up @@ -67,4 +68,8 @@ public function getAdmins(): Collection
{
return $this->admins;
}

public static function loadMetadata(ClassMetadata $metadata): void
{
}
}
5 changes: 5 additions & 0 deletions tests/Doctrine/Tests/Models/DDC5934/DDC5934Member.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\ClassMetadata;

/** @ORM\Entity() */
#[ORM\Entity]
Expand All @@ -23,4 +24,8 @@ public function __construct()
{
$this->contracts = new ArrayCollection();
}

public static function loadMetadata(ClassMetadata $metadata): void
{
}
}
6 changes: 6 additions & 0 deletions tests/Doctrine/Tests/Models/DDC964/DDC964Address.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

namespace Doctrine\Tests\Models\DDC964;

use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;

/** @Entity */
#[Entity]
class DDC964Address
{
/**
Expand Down Expand Up @@ -100,4 +102,8 @@ public function setStreet(string $street): void
{
$this->street = $street;
}

public static function loadMetadata(ClassMetadata $metadata): void
{
}
}
6 changes: 6 additions & 0 deletions tests/Doctrine/Tests/Models/DDC964/DDC964Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
namespace Doctrine\Tests\Models\DDC964;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\ManyToMany;

/** @Entity */
#[Entity]
class DDC964Group
{
/**
Expand Down Expand Up @@ -60,4 +62,8 @@ public function getUsers(): ArrayCollection
{
return $this->users;
}

public static function loadMetadata(ClassMetadata $metadata): void
{
}
}
130 changes: 130 additions & 0 deletions tests/Doctrine/Tests/ORM/Functional/Ticket/GH10473Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

declare(strict_types=1);

namespace Doctrine\Tests\ORM\Functional\Ticket;

use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Tools\ResolveTargetEntityListener;
use Doctrine\Tests\OrmTestCase;

class GH10473Test extends OrmTestCase
{
public function testMappedSuperclassAssociationsCanBeResolvedToEntities(): void
{
$em = $this->getTestEntityManager();

$resolveTargetEntity = new ResolveTargetEntityListener();

$resolveTargetEntity->addResolveTargetEntity(
GH10473BaseUser::class,
GH10473UserImplementation::class,
[]
);

$em->getEventManager()->addEventSubscriber($resolveTargetEntity);

$userMetadata = $em->getClassMetadata(GH10473UserImplementation::class);

self::assertFalse($userMetadata->isMappedSuperclass);
self::assertTrue($userMetadata->isInheritanceTypeNone());

$socialMediaAccountsMapping = $userMetadata->getAssociationMapping('socialMediaAccounts');
self::assertArrayNotHasKey('inherited', $socialMediaAccountsMapping);
self::assertTrue((bool) ($socialMediaAccountsMapping['type'] & ClassMetadata::TO_MANY));
self::assertFalse($socialMediaAccountsMapping['isOwningSide']);
self::assertSame(GH10473SocialMediaAccount::class, $socialMediaAccountsMapping['targetEntity']);
self::assertSame('user', $socialMediaAccountsMapping['mappedBy']);

$createdByMapping = $userMetadata->getAssociationMapping('createdBy');
self::assertArrayNotHasKey('inherited', $createdByMapping);
self::assertTrue((bool) ($createdByMapping['type'] & ClassMetadata::TO_ONE));
self::assertTrue($createdByMapping['isOwningSide']);
self::assertSame(GH10473UserImplementation::class, $createdByMapping['targetEntity']);
self::assertSame('createdUsers', $createdByMapping['inversedBy']);

$createdUsersMapping = $userMetadata->getAssociationMapping('createdUsers');
self::assertArrayNotHasKey('inherited', $createdUsersMapping);
self::assertTrue((bool) ($createdUsersMapping['type'] & ClassMetadata::TO_MANY));
self::assertFalse($createdUsersMapping['isOwningSide']);
self::assertSame(GH10473UserImplementation::class, $createdUsersMapping['targetEntity']);
self::assertSame('createdBy', $createdUsersMapping['mappedBy']);

$socialMediaAccountMetadata = $em->getClassMetadata(GH10473SocialMediaAccount::class);

self::assertFalse($socialMediaAccountMetadata->isMappedSuperclass);
self::assertTrue($socialMediaAccountMetadata->isInheritanceTypeNone());

$userMapping = $socialMediaAccountMetadata->getAssociationMapping('user');
self::assertArrayNotHasKey('inherited', $userMapping);
self::assertTrue((bool) ($userMapping['type'] & ClassMetadata::TO_ONE));
self::assertTrue($userMapping['isOwningSide']);
self::assertSame(GH10473UserImplementation::class, $userMapping['targetEntity']);
self::assertSame('socialMediaAccounts', $userMapping['inversedBy']);
}
}

/**
* @ORM\MappedSuperclass
*/
abstract class GH10473BaseUser
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
*
* @var int
*/
private $id;

/**
* @ORM\OneToMany(targetEntity="GH10473SocialMediaAccount", mappedBy="user")
*
* @var Collection
*/
private $socialMediaAccounts;

/**
* @ORM\ManyToOne(targetEntity="GH10473BaseUser", inversedBy="createdUsers")
*
* @var GH10473BaseUser
*/
private $createdBy;

/**
* @ORM\OneToMany(targetEntity="GH10473BaseUser", mappedBy="createdBy")
*
* @var Collection
*/
private $createdUsers;
}

/**
* @ORM\Entity
*/
class GH10473SocialMediaAccount
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
*
* @var int
*/
private $id;

/**
* @ORM\ManyToOne(targetEntity="GH10473BaseUser", inversedBy="socialMediaAccounts")
*
* @var GH10473BaseUser
*/
private $user;
}

/**
* @ORM\Entity
*/
class GH10473UserImplementation extends GH10473BaseUser
{
}
Loading