Skip to content

Commit 280e2a1

Browse files
[Discriminator] Add annotation, loader & subscriber
1 parent 8dc7b31 commit 280e2a1

10 files changed

+450
-0
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Camelot\DoctrineInheritanceMapping\Annotation;
6+
7+
use BadMethodCallException;
8+
9+
/**
10+
* @Annotation
11+
*/
12+
final class DiscriminatorMapItem
13+
{
14+
/** @var string|null */
15+
private $value;
16+
17+
public function __construct(array $data)
18+
{
19+
static::assertValid($data);
20+
$this->value = $data['value'];
21+
}
22+
23+
public function getValue(): ?string
24+
{
25+
return $this->value;
26+
}
27+
28+
private static function assertValid(array $data): void
29+
{
30+
if (!($data['value'] ?? false)) {
31+
throw new BadMethodCallException(sprintf('Value for annotation DiscriminatorMapItem is missing or empty.'));
32+
}
33+
}
34+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Camelot\DoctrineInheritanceMapping\Annotation;
6+
7+
use Doctrine\Common\Annotations\Reader;
8+
use Doctrine\Common\Collections\ArrayCollection;
9+
use Doctrine\Common\Collections\Collection;
10+
use Doctrine\Common\Persistence\Mapping\Driver\MappingDriver;
11+
use Doctrine\ORM\Configuration;
12+
use Doctrine\ORM\Mapping\ClassMetadata;
13+
use ReflectionClass;
14+
use RuntimeException;
15+
16+
/**
17+
* Based on work by Jasper Kuperus <github@jasperkuperus.nl>
18+
*/
19+
final class DiscriminatorMapLoader
20+
{
21+
/** @var Reader */
22+
private $reader;
23+
/** @var MappingDriver */
24+
private $mappingDriver;
25+
/** @var array */
26+
private $cachedMap;
27+
28+
public function __construct(Reader $reader, Configuration $config)
29+
{
30+
$this->reader = $reader;
31+
$this->mappingDriver = $config->getMetadataDriverImpl();
32+
$this->cachedMap = [];
33+
}
34+
35+
public function loadClassMetadata(ClassMetadata $classMetadata): void
36+
{
37+
$map = new ArrayCollection();
38+
$class = $classMetadata->name;
39+
40+
if (array_key_exists($class, $this->cachedMap)) {
41+
$this->overrideMetadata($classMetadata, $class);
42+
43+
return;
44+
}
45+
$this->extractEntry($map, $class);
46+
if (!$map->containsKey($class)) {
47+
return;
48+
}
49+
$this->checkFamily($map, $class);
50+
$this->createEntries($map);
51+
$this->overrideMetadata($classMetadata, $class);
52+
}
53+
54+
private function createEntries(Collection $map)
55+
{
56+
$discriminatorMap = array_flip($map->toArray());
57+
foreach ($map as $className => $discriminatorItem) {
58+
$this->cachedMap[$className]['map'] = $discriminatorMap;
59+
$this->cachedMap[$className]['discr'] = $map->get($className);
60+
}
61+
}
62+
63+
/**
64+
* Set the discriminator map, discr value & subclasses
65+
*/
66+
private function overrideMetadata(ClassMetadata $classMetadata, $class)
67+
{
68+
$classMetadata->discriminatorMap = $this->cachedMap[$class]['map'];
69+
$classMetadata->discriminatorValue = $this->cachedMap[$class]['discr'];
70+
if (isset($this->cachedMap[$class]['isParent']) && true === $this->cachedMap[$class]['isParent']) {
71+
$subclasses = $this->cachedMap[$class]['map'];
72+
unset($subclasses[$this->cachedMap[$class]['discr']]);
73+
$classMetadata->subClasses = array_values($subclasses);
74+
}
75+
}
76+
77+
/**
78+
* Build the whole map.
79+
*
80+
* @throws \ReflectionException
81+
*/
82+
private function checkFamily(Collection $map, string $class): void
83+
{
84+
$rc = new ReflectionClass($class);
85+
$parent = $rc->getParentClass() ? $rc->getParentClass()->name : null;
86+
if ($parent !== null) {
87+
$this->checkFamily($map, $parent);
88+
}
89+
$this->cachedMap[$class]['isParent'] = true;
90+
$this->checkChildren($map, $class);
91+
}
92+
93+
private function checkChildren(Collection $map, string $class): void
94+
{
95+
foreach ($this->mappingDriver->getAllClassNames() as $name) {
96+
$childRc = new ReflectionClass($name);
97+
$classParent = $childRc->getParentClass() ? $childRc->getParentClass()->name : null;
98+
if (!$classParent) {
99+
continue;
100+
}
101+
if ($map->containsKey($name) || $classParent !== $class) {
102+
continue;
103+
}
104+
$this->extractEntry($map, $name);
105+
$this->checkChildren($map, $name);
106+
}
107+
}
108+
109+
private function extractEntry(Collection $map, string $class): void
110+
{
111+
$rc = new ReflectionClass($class);
112+
foreach ($this->reader->getClassAnnotations($rc) as $annotation) {
113+
if (!$annotation instanceof DiscriminatorMapItem) {
114+
continue;
115+
}
116+
$value = $annotation->getValue();
117+
if ($map->containsKey($value)) {
118+
throw new RuntimeException(sprintf("Found duplicate discriminator map entry '%s' in %s", $value, $class));
119+
}
120+
$map->set($class, $value);
121+
}
122+
}
123+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace Camelot\DoctrineInheritanceMapping\EventSubscriber;
4+
5+
use Camelot\DoctrineInheritanceMapping\Annotation\DiscriminatorMapLoader;
6+
use Doctrine\Common\EventSubscriber;
7+
use Doctrine\ORM\Event\LoadClassMetadataEventArgs;
8+
use Doctrine\ORM\Events;
9+
10+
class DiscriminatorSubscriber implements EventSubscriber
11+
{
12+
/** @var DiscriminatorMapLoader */
13+
private $loader;
14+
15+
public function __construct(DiscriminatorMapLoader $loader)
16+
{
17+
$this->loader = $loader;
18+
}
19+
20+
public function getSubscribedEvents(): iterable
21+
{
22+
return [Events::loadClassMetadata];
23+
}
24+
25+
public function loadClassMetadata(LoadClassMetadataEventArgs $event): void
26+
{
27+
$this->loader->loadClassMetadata($event->getClassMetadata());
28+
}
29+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Camelot\DoctrineInheritanceMapping\Tests\Annotation;
6+
7+
use BadMethodCallException;
8+
use Camelot\DoctrineInheritanceMapping\Annotation\DiscriminatorMapItem;
9+
use PHPUnit\Framework\TestCase;
10+
11+
/**
12+
* @covers \Camelot\DoctrineInheritanceMapping\Annotation\DiscriminatorMapItem
13+
*/
14+
final class DiscriminatorMapItemTest extends TestCase
15+
{
16+
public function providerDiscriminatorMap(): iterable
17+
{
18+
yield 'Null value' => [['value' => null]];
19+
yield 'Empty value' => [['value' => '']];
20+
}
21+
22+
/**
23+
* @dataProvider providerDiscriminatorMap
24+
*/
25+
public function testInvalidData(array $data): void
26+
{
27+
$this->expectException(BadMethodCallException::class);
28+
$this->expectExceptionMessage('Value for annotation DiscriminatorMapItem is missing or empty');
29+
30+
new DiscriminatorMapItem($data);
31+
}
32+
33+
public function testGetValue(): void
34+
{
35+
$discriminatorMapItem = new DiscriminatorMapItem(['value' => 'foo']);
36+
37+
static::assertSame('foo', $discriminatorMapItem->getValue());
38+
}
39+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Camelot\DoctrineInheritanceMapping\Tests\Annotation;
6+
7+
use Camelot\DoctrineInheritanceMapping\Annotation\DiscriminatorMapLoader;
8+
use Camelot\DoctrineInheritanceMapping\Tests\DiscriminatorMapLoaderTrait;
9+
use Camelot\DoctrineInheritanceMapping\Tests\Fixtures\Entity\SingleTable;
10+
use Camelot\DoctrineInheritanceMapping\Tests\Fixtures\Entity\SingleTableChild;
11+
use Camelot\DoctrineInheritanceMapping\Tests\Fixtures\Entity\SingleTableGrandchild;
12+
use Doctrine\ORM\Mapping\ClassMetadata;
13+
use PHPUnit\Framework\TestCase;
14+
15+
/**
16+
* @covers \Camelot\DoctrineInheritanceMapping\Annotation\DiscriminatorMapLoader
17+
*/
18+
final class DiscriminatorMapLoaderTest extends TestCase
19+
{
20+
use DiscriminatorMapLoaderTrait;
21+
22+
public function testLoadClassMetadata(): array
23+
{
24+
$classMetadata = new ClassMetadata(SingleTable::class);
25+
$loader = $this->createDiscriminatorMapLoader();
26+
27+
static::assertMetadata($classMetadata, $loader);
28+
29+
return [$classMetadata, $loader];
30+
}
31+
32+
/**
33+
* @depends testLoadClassMetadata
34+
*/
35+
public function testLoadClassMetadataCaching($dependency): void
36+
{
37+
[$classMetadata, $loader] = $dependency;
38+
static::assertMetadata($classMetadata, $loader);
39+
}
40+
41+
private static function assertMetadata(ClassMetadata $classMetadata, DiscriminatorMapLoader $loader)
42+
{
43+
$expected = [
44+
'SingleTable' => SingleTable::class,
45+
'SingleTableChild' => SingleTableChild::class,
46+
'SingleTableGrandchild' => SingleTableGrandchild::class,
47+
];
48+
$loader->loadClassMetadata($classMetadata);
49+
50+
static::assertSame($expected, $classMetadata->discriminatorMap);
51+
}
52+
}

tests/DiscriminatorMapLoaderTrait.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Camelot\DoctrineInheritanceMapping\Tests;
6+
7+
use Camelot\DoctrineInheritanceMapping\Annotation\DiscriminatorMapLoader;
8+
use Doctrine\Common\Annotations\AnnotationException;
9+
use Doctrine\Common\Annotations\AnnotationReader;
10+
use Doctrine\Common\Annotations\DocParser;
11+
use Doctrine\ORM\Configuration;
12+
use Doctrine\ORM\Mapping\Driver\AnnotationDriver;
13+
14+
trait DiscriminatorMapLoaderTrait
15+
{
16+
private function createDiscriminatorMapLoader(): DiscriminatorMapLoader
17+
{
18+
try {
19+
$reader = new AnnotationReader(new DocParser());
20+
} catch (AnnotationException $e) {
21+
static::fail($e->getMessage());
22+
}
23+
$driver = new AnnotationDriver($reader);
24+
$driver->addPaths([__DIR__ . '/Fixtures/Entity']);
25+
$config = new Configuration();
26+
$config->setMetadataDriverImpl($driver);
27+
28+
return new DiscriminatorMapLoader($reader, $config);
29+
}
30+
31+
abstract public static function fail(string $message);
32+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Camelot\DoctrineInheritanceMapping\Tests\EventSubscriber;
6+
7+
use Camelot\DoctrineInheritanceMapping\EventSubscriber\DiscriminatorSubscriber;
8+
use Camelot\DoctrineInheritanceMapping\Tests\DiscriminatorMapLoaderTrait;
9+
use Camelot\DoctrineInheritanceMapping\Tests\Fixtures\Entity\SingleTable;
10+
use Doctrine\ORM\Event\LoadClassMetadataEventArgs;
11+
use Doctrine\ORM\Mapping\ClassMetadata;
12+
use PHPUnit\Framework\TestCase;
13+
14+
/**
15+
* @covers \Camelot\DoctrineInheritanceMapping\EventSubscriber\DiscriminatorSubscriber
16+
*/
17+
final class DiscriminatorSubscriberTest extends TestCase
18+
{
19+
use DiscriminatorMapLoaderTrait;
20+
21+
public function testGetSubscribedEvents(): void
22+
{
23+
$subscriber = $this->getDiscriminatorSubscriber();
24+
static::assertSame(['loadClassMetadata'], $subscriber->getSubscribedEvents());
25+
}
26+
27+
public function testLoadClassMetadata(): void
28+
{
29+
$classMetadata = new ClassMetadata(SingleTable::class);
30+
$event = $this->createMock(LoadClassMetadataEventArgs::class);
31+
$event
32+
->expects($this->once())
33+
->method('getClassMetadata')
34+
->willReturn($classMetadata)
35+
;
36+
$subscriber = $this->getDiscriminatorSubscriber();
37+
$subscriber->loadClassMetadata($event);
38+
}
39+
40+
private function getDiscriminatorSubscriber(): DiscriminatorSubscriber
41+
{
42+
return new DiscriminatorSubscriber($this->createDiscriminatorMapLoader());
43+
}
44+
}

tests/Fixtures/Entity/SingleTable.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace Camelot\DoctrineInheritanceMapping\Tests\Fixtures\Entity;
4+
5+
use Camelot\DoctrineInheritanceMapping\Annotation\DiscriminatorMapItem;
6+
use Doctrine\ORM\Mapping as ORM;
7+
use Ramsey\Uuid\UuidInterface;
8+
9+
/**
10+
* @ORM\Entity()
11+
* @ORM\InheritanceType("SINGLE_TABLE")
12+
* @DiscriminatorMapItem(value="SingleTable")
13+
*/
14+
class SingleTable
15+
{
16+
/** @var UuidInterface|null */
17+
protected $id = null;
18+
/** @var string|null */
19+
protected $title = null;
20+
21+
public function getId(): ?UuidInterface
22+
{
23+
return $this->id;
24+
}
25+
26+
public function getTitle(): ?string
27+
{
28+
return $this->title;
29+
}
30+
31+
public function setTitle(string $title): self
32+
{
33+
$this->title = $title;
34+
35+
return $this;
36+
}
37+
}

0 commit comments

Comments
 (0)