Skip to content

Commit 424578e

Browse files
authored
feat: add Doctrine inheritance support (discriminator) (#382)
1 parent 181fac2 commit 424578e

23 files changed

+258
-145
lines changed

bin/compile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ rm -rf tmp/original
99
php schema.phar generate tmp/original tests/e2e/schema.yml -n -vv --ansi;
1010

1111
diff tests/e2e/original/App/Schema/Entity/Brand.php tmp/original/App/Schema/Entity/Brand.php;
12+
diff tests/e2e/original/App/Schema/Entity/ContactPoint.php tmp/original/App/Schema/Entity/ContactPoint.php;
1213
diff tests/e2e/original/App/Schema/Entity/Person.php tmp/original/App/Schema/Entity/Person.php;
1314
diff tests/e2e/original/App/Schema/Entity/PostalAddress.php tmp/original/App/Schema/Entity/PostalAddress.php;
1415
diff tests/e2e/original/App/Schema/Entity/Thing.php tmp/original/App/Schema/Entity/Thing.php;
@@ -21,6 +22,7 @@ cp -r tests/e2e/customized tmp/
2122
php schema.phar generate tmp/customized tests/e2e/schema.yml -n -vv --ansi;
2223

2324
diff tests/e2e/customized/App/Schema/Entity/Brand.php tmp/customized/App/Schema/Entity/Brand.php;
25+
diff tests/e2e/customized/App/Schema/Entity/ContactPoint.php tmp/customized/App/Schema/Entity/ContactPoint.php;
2426
diff tests/e2e/customized/App/Schema/Entity/Person.php tmp/customized/App/Schema/Entity/Person.php;
2527
diff tests/e2e/customized/App/Schema/Entity/PostalAddress.php tmp/customized/App/Schema/Entity/PostalAddress.php;
2628
diff tests/e2e/customized/App/Schema/Entity/Thing.php tmp/customized/App/Schema/Entity/Thing.php;

phpstan.neon

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,13 @@ parameters:
4545
relations: string[],
4646
debug: boolean,
4747
apiPlatformOldAttributes: boolean,
48-
id: array{generate: boolean, generationStrategy: string, writable: boolean, onClass: string},
48+
id: array{generate: boolean, generationStrategy: string, writable: boolean},
4949
useInterface: boolean,
5050
checkIsGoodRelations: boolean,
5151
header: ?string,
5252
namespaces: array{prefix: ?string, entity: string, enum: string, interface: string},
5353
uses: array<string, array{alias: ?string}>,
54-
doctrine: array{useCollection: boolean, resolveTargetEntityConfigPath: ?string, resolveTargetEntityConfigType: 'XML'|'yaml', inheritanceAttributes: array<string, (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal)[]>},
54+
doctrine: array{useCollection: boolean, resolveTargetEntityConfigPath: ?string, resolveTargetEntityConfigType: 'XML'|'yaml', inheritanceAttributes: array<string, (int|bool|null|string|string[]|string[][]|\Nette\PhpGenerator\Literal)[]>, inheritanceType: 'JOINED'|'SINGLE_TABLE'|'SINGLE_COLLECTION'|'TABLE_PER_CLASS'|'COLLECTION_PER_CLASS'|'NONE'},
5555
validator: array{assertType: boolean},
5656
author: false|string,
5757
fieldVisibility: string,

src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ final class ApiPlatformCoreAttributeGenerator extends AbstractAttributeGenerator
4444
*/
4545
public function generateClassAttributes(Class_ $class): array
4646
{
47-
if ($class->isAbstract || $class->isEnum()) {
47+
if ($class->hasChild || $class->isEnum()) {
4848
return [];
4949
}
5050

src/AttributeGenerator/DoctrineMongoDBAttributeGenerator.php

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
use ApiPlatform\SchemaGenerator\Model\Class_;
1919
use ApiPlatform\SchemaGenerator\Model\Property;
2020
use ApiPlatform\SchemaGenerator\Model\Use_;
21+
use Nette\PhpGenerator\Literal;
22+
use function Symfony\Component\String\u;
2123

2224
/**
2325
* Doctrine MongoDB attribute generator.
@@ -36,17 +38,29 @@ public function generateClassAttributes(Class_ $class): array
3638
}
3739

3840
$attributes = [];
39-
if ($class->isAbstract) {
40-
if ($inheritanceAttributes = $this->config['doctrine']['inheritanceAttributes']) {
41-
$attributes = [];
42-
foreach ($inheritanceAttributes as $attributeName => $attributeArgs) {
43-
$attributes[] = new Attribute($attributeName, $attributeArgs);
41+
if ($class->hasChild && ($inheritanceAttributes = $this->config['doctrine']['inheritanceAttributes'])) {
42+
foreach ($inheritanceAttributes as $attributeName => $attributeArgs) {
43+
$attributes[] = new Attribute($attributeName, $attributeArgs);
44+
}
45+
} elseif ($class->isAbstract) {
46+
$attributes[] = new Attribute('MongoDB\MappedSuperclass');
47+
} elseif ($class->hasChild && $class->isReferencedBy) {
48+
$parentNames = [$class->name()];
49+
$childNames = [];
50+
while (!empty($parentNames)) {
51+
$directChildren = [];
52+
foreach ($parentNames as $parentName) {
53+
$directChildren = array_merge($directChildren, array_filter($this->classes, fn (Class_ $childClass) => $parentName === $childClass->parent()));
4454
}
45-
46-
return $attributes;
55+
$parentNames = array_keys($directChildren);
56+
$childNames = array_merge($childNames, array_keys(array_filter($directChildren, fn (Class_ $childClass) => !$childClass->isAbstract)));
4757
}
58+
$mapNames = array_merge([$class->name()], $childNames);
4859

49-
$attributes[] = new Attribute('MongoDB\MappedSuperclass');
60+
$attributes[] = new Attribute('MongoDB\Document');
61+
$attributes[] = new Attribute('MongoDB\InheritanceType', [\in_array($this->config['doctrine']['inheritanceType'], ['SINGLE_COLLECTION', 'COLLECTION_PER_CLASS', 'NONE'], true) ? $this->config['doctrine']['inheritanceType'] : 'SINGLE_COLLECTION']);
62+
$attributes[] = new Attribute('MongoDB\DiscriminatorField', ['discr']);
63+
$attributes[] = new Attribute('MongoDB\DiscriminatorMap', [array_reduce($mapNames, fn (array $map, string $mapName) => $map + [u($mapName)->camel()->toString() => new Literal(sprintf('%s::class', $mapName))], [])]);
5064
} else {
5165
$attributes[] = new Attribute('MongoDB\Document');
5266
}
@@ -145,16 +159,6 @@ private function getRelationName(Property $property, string $className): ?string
145159
return null;
146160
}
147161

148-
if ($reference->isAbstract && !$this->config['doctrine']['inheritanceAttributes']) {
149-
$this->logger ? $this->logger->warning(
150-
<<<'EOD'
151-
Cannot create a relation from the property "{property}" of the class "{class}" to the class "{referenceClass}" because the latter is a Mapped Superclass.
152-
If you want to add a relation anyway, use an inheritance mapping strategy and a discriminator column to do so.
153-
EOD, ['property' => $property->name(), 'class' => $className, 'referenceClass' => $reference->shortName()]) : null;
154-
155-
return null;
156-
}
157-
158162
return $reference->interfaceName() ?: $reference->name();
159163
}
160164

src/AttributeGenerator/DoctrineOrmAttributeGenerator.php

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
use ApiPlatform\SchemaGenerator\Model\Class_;
1919
use ApiPlatform\SchemaGenerator\Model\Property;
2020
use ApiPlatform\SchemaGenerator\Model\Use_;
21+
use Nette\PhpGenerator\Literal;
22+
use function Symfony\Component\String\u;
2123

2224
/**
2325
* Doctrine attribute generator.
@@ -51,17 +53,29 @@ public function generateClassAttributes(Class_ $class): array
5153
}
5254

5355
$attributes = [];
54-
if ($class->isAbstract) {
55-
if ($inheritanceAttributes = $this->config['doctrine']['inheritanceAttributes']) {
56-
$attributes = [];
57-
foreach ($inheritanceAttributes as $attributeName => $attributeArgs) {
58-
$attributes[] = new Attribute($attributeName, $attributeArgs);
56+
if ($class->hasChild && ($inheritanceAttributes = $this->config['doctrine']['inheritanceAttributes'])) {
57+
foreach ($inheritanceAttributes as $attributeName => $attributeArgs) {
58+
$attributes[] = new Attribute($attributeName, $attributeArgs);
59+
}
60+
} elseif ($class->isAbstract) {
61+
$attributes[] = new Attribute('ORM\MappedSuperclass');
62+
} elseif ($class->hasChild && $class->isReferencedBy) {
63+
$parentNames = [$class->name()];
64+
$childNames = [];
65+
while (!empty($parentNames)) {
66+
$directChildren = [];
67+
foreach ($parentNames as $parentName) {
68+
$directChildren = array_merge($directChildren, array_filter($this->classes, fn (Class_ $childClass) => $parentName === $childClass->parent()));
5969
}
60-
61-
return $attributes;
70+
$parentNames = array_keys($directChildren);
71+
$childNames = array_merge($childNames, array_keys(array_filter($directChildren, fn (Class_ $childClass) => !$childClass->isAbstract)));
6272
}
73+
$mapNames = array_merge([$class->name()], $childNames);
6374

64-
$attributes[] = new Attribute('ORM\MappedSuperclass');
75+
$attributes[] = new Attribute('ORM\Entity');
76+
$attributes[] = new Attribute('ORM\InheritanceType', [\in_array($this->config['doctrine']['inheritanceType'], ['JOINED', 'SINGLE_TABLE', 'TABLE_PER_CLASS', 'NONE'], true) ? $this->config['doctrine']['inheritanceType'] : 'JOINED']);
77+
$attributes[] = new Attribute('ORM\DiscriminatorColumn', ['name' => 'discr']);
78+
$attributes[] = new Attribute('ORM\DiscriminatorMap', [array_reduce($mapNames, fn (array $map, string $mapName) => $map + [u($mapName)->camel()->toString() => new Literal(sprintf('%s::class', $mapName))], [])]);
6579
} else {
6680
$attributes[] = new Attribute('ORM\Entity');
6781
}
@@ -72,8 +86,6 @@ public function generateClassAttributes(Class_ $class): array
7286
}
7387

7488
$attributes[] = new Attribute('ORM\Table', ['name' => strtolower($class->name())]);
75-
76-
return $attributes;
7789
}
7890

7991
return $attributes;
@@ -275,16 +287,6 @@ private function getRelationName(Property $property, string $className): ?string
275287
return null;
276288
}
277289

278-
if ($reference->isAbstract && !$this->config['doctrine']['inheritanceAttributes']) {
279-
$this->logger ? $this->logger->warning(
280-
<<<'EOD'
281-
Cannot create a relation from the property "{property}" of the class "{class}" to the class "{referenceClass}" because the latter is a Mapped Superclass.
282-
If you want to add a relation anyway, use an inheritance mapping strategy and a discriminator column to do so.
283-
EOD, ['property' => $property->name(), 'class' => $className, 'referenceClass' => $reference->shortName()]) : null;
284-
285-
return null;
286-
}
287-
288290
if (null !== $reference->interfaceName()) {
289291
if (isset($this->config['types'][$reference->name()]['namespaces']['interface'])) {
290292
return sprintf('%s\\%s', $this->config['types'][$reference->name()]['namespaces']['interface'], $reference->interfaceName());

src/ClassMutator/ClassIdAppender.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,9 @@ public function __construct(IdPropertyGeneratorInterface $idPropertyGenerator, a
3737
public function __invoke(Class_ $class, array $context): void
3838
{
3939
if (
40-
$class->isEnum()
41-
|| $class->isEmbeddable
42-
|| ($class->hasParent() && 'parent' === $this->config['id']['onClass'])
43-
|| ($class->hasChild && 'child' === $this->config['id']['onClass'])
40+
$class->isEmbeddable
41+
|| $class->isEnum()
42+
|| $class->hasParent()
4443
) {
4544
return;
4645
}

src/Model/Class_.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ abstract class Class_
4242
private array $constants = [];
4343
public bool $hasConstructor = false;
4444
public bool $parentHasConstructor = false;
45+
/** @var Class_[] */
46+
public array $isReferencedBy = [];
4547
public bool $isAbstract = false;
4648
public bool $hasChild = false;
4749
public bool $isEmbeddable = false;

src/SchemaGeneratorConfiguration.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ public function getConfigTreeBuilder(): TreeBuilder
111111
->booleanNode('generate')->defaultTrue()->info('Automatically add an id field to entities')->end()
112112
->enumNode('generationStrategy')->defaultValue('auto')->values(['auto', 'none', 'uuid', 'mongoid'])->info('The ID generation strategy to use ("none" to not let the database generate IDs).')->end()
113113
->booleanNode('writable')->defaultFalse()->info('Is the ID writable? Only applicable if "generationStrategy" is "uuid".')->end()
114-
->enumNode('onClass')->defaultValue('child')->values(['child', 'parent'])->info('Set to "child" to generate the id on the child class, and "parent" to use the parent class instead.')->end()
115114
->end()
116115
->end()
117116
->booleanNode('useInterface')->defaultFalse()->info('Generate interfaces and use Doctrine\'s Resolve Target Entity feature')->end()
@@ -152,6 +151,7 @@ public function getConfigTreeBuilder(): TreeBuilder
152151
->then($transformOmap)
153152
->end()
154153
->end()
154+
->enumNode('inheritanceType')->defaultValue('JOINED')->values(['JOINED', 'SINGLE_TABLE', 'SINGLE_COLLECTION', 'TABLE_PER_CLASS', 'COLLECTION_PER_CLASS', 'NONE'])->info('The inheritance type to use when an entity is referenced by another and has child')->end()
155155
->end()
156156
->end()
157157
->arrayNode('validator')

src/TypesGenerator.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ public function generate(array $graphs, array $config): array
136136
$classes = array_intersect_key($classes, array_flip($typeNamesToGenerate));
137137
$types = array_intersect_key($types, array_flip($typeNamesToGenerate));
138138

139+
$referencedByClasses = [];
140+
139141
// Second pass
140142
foreach ($classes as $class) {
141143
/** @var $class SchemaClass */
@@ -154,6 +156,7 @@ public function generate(array $graphs, array $config): array
154156
$typeName = $property->rangeName;
155157
if (isset($classes[$typeName])) {
156158
$property->reference = $classes[$typeName];
159+
$referencedByClasses[$typeName][$class->name()] = $class;
157160
}
158161
}
159162

@@ -162,8 +165,11 @@ public function generate(array $graphs, array $config): array
162165

163166
// Third pass
164167
foreach ($classes as $class) {
168+
$class->isReferencedBy = $referencedByClasses[$class->name()] ?? [];
165169
/* @var $class SchemaClass */
166-
$class->isAbstract = $config['types'][$class->name()]['abstract'] ?? $class->hasChild;
170+
$class->isAbstract = $config['types'][$class->name()]['abstract']
171+
// Class is abstract if it has child and if it is not referenced by a relation
172+
?? ($class->hasChild && !$class->isReferencedBy);
167173

168174
// When including all properties, ignore properties already set on parent
169175
if (($config['types'][$class->name()]['allProperties'] ?? true) && isset($classes[$class->parent()])) {

tests/AttributeGenerator/ApiPlatformCoreAttributeGeneratorTest.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,9 @@ public function provideGenerateClassAttributesCases(): \Generator
7878
];
7979
yield 'with operations (old)' => [$class, [new Attribute('ApiResource', ['iri' => 'https://schema.org/WithOperations', 'itemOperations' => ['get' => ['route_name' => 'api_about_get']], 'collectionOperations' => []])], true];
8080

81-
$class = new SchemaClass('Abstract', new RdfResource('https://schema.org/Abstract'));
82-
$class->isAbstract = true;
83-
yield 'abstract' => [$class, []];
81+
$class = new SchemaClass('HasChild', new RdfResource('https://schema.org/HasChild'));
82+
$class->hasChild = true;
83+
yield 'has child' => [$class, []];
8484

8585
$resource = new RdfResource('https://schema.org/MyEnum', new RdfGraph());
8686
$resource->add('rdfs:subClassOf', ['type' => 'uri', 'value' => TypesGenerator::SCHEMA_ORG_ENUMERATION]);

0 commit comments

Comments
 (0)