Skip to content

Commit a5ec996

Browse files
committed
Make Annotations/Attribute mapping drivers report fields for the classes where they are declared
This PR will make the annotations and attribute mapping drivers more consistently report mapping configuration for the classes where it is declared. This is necessary to be able to catch mis-configurations in `ClassMetadataFactory`. Fixes doctrine#10417, closes doctrine#10450, closes doctrine#10454. #### Current situation The annotations mapping driver has the following condition to skip properties that are reported by the PHP reflection API: https://github.com/doctrine/orm/blob/69c7791ba256d947ddb1aafe5f2439ab31704937/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php#L345-L357 This code has been there basically unchanged since the initial 2.0 release. The same condition can be found in the attribute driver, probably it has been copied when attributes were added. I _think_ what the driver tries to do here is to deal with the fact that Reflection will also report `public`/`protected` properties inherited from parent classes. This is supported by the observation (see doctrine#5744) that e. g. YAML and XML drivers do not contain this logic. The conditions are not precise enough for edge cases. They lead to some fields and configuration values not even being reported by the driver. Only since the fields would be "discovered" again when reflecting on subclasses, they eventually end up in class metadata structures for the subclasses. In one case of inherited ID generator mappings, the `ClassMetadataFactory` would also rely on this behaviour. Two potential bugs that can result from this are demonstrated in doctrine#10450 and doctrine#10454. #### Suggested solution In order to find a more reliable way of separating properties that are merely reported again in subclasses from those that are actual re-declarations, use the information already available in `ClassMetadata`. In particular, `declared` tells us in which non-transient class a "field" was first seen. Make the mapping driver skip only those properties for which we already know that they have been declared in parent classes, and skip them only when the observed declaring class matches the expectation. For all other properties, report them to `ClassMetadataFactory` and let that deal with consistency checking/error handling. doctrine#10450 and doctrine#10454 are merged into this PR to show that they pass now. #### Soft deprecation strategy When users re-declare (overwrite) mapped properties inherited from mapped superclasses and/or other entities, the new behaviour may cause their mapping configuration to be rejected as invalid. This applies only to configurations that were never allowed as per the documentation. For some cases, we missed the opportunity to reject the configuration with an exception early on. In other cases, we had the exception-throwing code in place, but due to the driver's behaviour, it was never reached. To avoid throwing new/surprising exceptions (even for misconfigurations) during a minor version upgrade, the new driver mode is opt-in. Users will have to set the `$reportFieldsWhereDeclared` constructor parameters to `true` for the `AnnotationDriver` and/or `AttributesDriver`. Unless they do so, a deprecation warning will be raised. In 3.0, the "new" mode will become the default. The constructor parameter can be deprecated (as of ORM 3.1, probably) and is a no-op. We should follow up in other places (DoctrineBundle, ... – what else?) to make this driver parameter an easy-to-change configuration setting. # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # Date: Tue Jan 24 20:28:32 2023 +0000 # # On branch fix-mapping-driver-load # Your branch is up-to-date with 'mpdude/fix-mapping-driver-load'. # # Changes to be committed: # modified: UPGRADE.md # modified: lib/Doctrine/ORM/Configuration.php # modified: lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php # modified: lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php # modified: lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php # new file: lib/Doctrine/ORM/Mapping/Driver/ReflectionBasedDriver.php # modified: lib/Doctrine/ORM/ORMSetup.php # modified: tests/Doctrine/Tests/DoctrineTestCase.php # modified: tests/Doctrine/Tests/Models/Company/CompanyFlexContract.php # modified: tests/Doctrine/Tests/ORM/ConfigurationTest.php # modified: tests/Doctrine/Tests/ORM/Functional/EnumTest.php # modified: tests/Doctrine/Tests/ORM/Functional/MergeProxiesTest.php # modified: tests/Doctrine/Tests/ORM/Functional/ReadonlyPropertiesTest.php # modified: tests/Doctrine/Tests/ORM/Functional/Ticket/DDC719Test.php # new file: tests/Doctrine/Tests/ORM/Functional/Ticket/GH10450Test.php # new file: tests/Doctrine/Tests/ORM/Functional/Ticket/GH10454Test.php # modified: tests/Doctrine/Tests/ORM/Functional/Ticket/GH5998Test.php # modified: tests/Doctrine/Tests/ORM/Mapping/AttributeDriverTest.php # modified: tests/Doctrine/Tests/ORM/Query/SelectSqlGenerationTest.php # modified: tests/Doctrine/Tests/OrmFunctionalTestCase.php # modified: tests/Doctrine/Tests/OrmTestCase.php # # Untracked files: # phpunit.xml #
1 parent 37c8953 commit a5ec996

21 files changed

+413
-53
lines changed

UPGRADE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
# Upgrade to 2.16
2+
3+
## Changing the way how reflection-based mapping drivers report fields, deprecated the "old" mode
4+
5+
In ORM 3.0, a change will be made regarding how the `AttributeDriver` reports field mappings.
6+
This change is necessary to be able to detect (and reject) some invalid mapping configurations.
7+
8+
To avoid surprises during 2.x upgrades, the new mode is opt-in. It can be activated on the
9+
`AttributeDriver` and `AnnotationDriver` by setting the `$reportFieldsWhereDeclared`
10+
constructor parameter to `true`. It will cause `MappingException`s to be thrown when invalid
11+
configurations are detected.
12+
13+
Not enabling the new mode will cause a deprecation notice to be raised. In ORM 3.0,
14+
only the new mode will be available.
15+
116
# Upgrade to 2.15
217

318
## Deprecated configuring `JoinColumn` on the inverse side of one-to-one associations

lib/Doctrine/ORM/Configuration.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ public function setMetadataDriverImpl(MappingDriver $driverImpl)
164164
*
165165
* @return AnnotationDriver
166166
*/
167-
public function newDefaultAnnotationDriver($paths = [], $useSimpleAnnotationReader = true)
167+
public function newDefaultAnnotationDriver($paths = [], $useSimpleAnnotationReader = true, bool $reportFieldsWhereDeclared = false)
168168
{
169169
Deprecation::trigger(
170170
'doctrine/orm',
@@ -203,7 +203,8 @@ public function newDefaultAnnotationDriver($paths = [], $useSimpleAnnotationRead
203203

204204
return new AnnotationDriver(
205205
$reader,
206-
(array) $paths
206+
(array) $paths,
207+
$reportFieldsWhereDeclared
207208
);
208209
}
209210

lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ protected function doLoadMetadata($class, $parent, $rootEntityFound, array $nonS
110110
if ($parent) {
111111
$class->setInheritanceType($parent->inheritanceType);
112112
$class->setDiscriminatorColumn($parent->discriminatorColumn);
113-
$class->setIdGeneratorType($parent->generatorType);
113+
$this->inheritIdGeneratorMapping($class, $parent);
114114
$this->addInheritedFields($class, $parent);
115115
$this->addInheritedRelations($class, $parent);
116116
$this->addInheritedEmbeddedClasses($class, $parent);
@@ -138,12 +138,8 @@ protected function doLoadMetadata($class, $parent, $rootEntityFound, array $nonS
138138
throw MappingException::reflectionFailure($class->getName(), $e);
139139
}
140140

141-
// If this class has a parent the id generator strategy is inherited.
142-
// However this is only true if the hierarchy of parents contains the root entity,
143-
// if it consists of mapped superclasses these don't necessarily include the id field.
144-
if ($parent && $rootEntityFound) {
145-
$this->inheritIdGeneratorMapping($class, $parent);
146-
} else {
141+
// Complete id generator mapping when the generator was declared/added in this class
142+
if ($class->identifier && (! $parent || ! $parent->identifier)) {
147143
$this->completeIdGeneratorMapping($class);
148144
}
149145

lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
class AnnotationDriver extends CompatibilityAnnotationDriver
3535
{
3636
use ColocatedMappingDriver;
37+
use ReflectionBasedDriver;
3738

3839
/**
3940
* The annotation reader.
@@ -60,7 +61,7 @@ class AnnotationDriver extends CompatibilityAnnotationDriver
6061
* @param Reader $reader The AnnotationReader to use
6162
* @param string|string[]|null $paths One or multiple paths where mapping classes can be found.
6263
*/
63-
public function __construct($reader, $paths = null)
64+
public function __construct($reader, $paths = null, bool $reportFieldsWhereDeclared = false)
6465
{
6566
Deprecation::trigger(
6667
'doctrine/orm',
@@ -70,6 +71,17 @@ public function __construct($reader, $paths = null)
7071
$this->reader = $reader;
7172

7273
$this->addPaths((array) $paths);
74+
75+
if (! $reportFieldsWhereDeclared) {
76+
Deprecation::trigger(
77+
'doctrine/orm',
78+
'https://github.com/doctrine/orm/pull/10455',
79+
'In ORM 3.0, the AttributeDriver will report fields for the classes where they are declared. This may uncover invalid mapping configurations. To opt into the new mode also with the AnnotationDriver today, set the "reportFieldsWhereDeclared" constructor parameter to true.',
80+
self::class
81+
);
82+
}
83+
84+
$this->reportFieldsWhereDeclared = $reportFieldsWhereDeclared;
7385
}
7486

7587
/**
@@ -348,15 +360,7 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad
348360

349361
// Evaluate annotations on properties/fields
350362
foreach ($class->getProperties() as $property) {
351-
if (
352-
$metadata->isMappedSuperclass && ! $property->isPrivate()
353-
||
354-
$metadata->isInheritedField($property->name)
355-
||
356-
$metadata->isInheritedAssociation($property->name)
357-
||
358-
$metadata->isInheritedEmbeddedClass($property->name)
359-
) {
363+
if ($this->isRepeatedPropertyDeclaration($property, $metadata)) {
360364
continue;
361365
}
362366

lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
class AttributeDriver extends CompatibilityAnnotationDriver
3131
{
3232
use ColocatedMappingDriver;
33+
use ReflectionBasedDriver;
3334

3435
private const ENTITY_ATTRIBUTE_CLASSES = [
3536
Mapping\Entity::class => 1,
@@ -53,7 +54,7 @@ class AttributeDriver extends CompatibilityAnnotationDriver
5354
protected $reader;
5455

5556
/** @param array<string> $paths */
56-
public function __construct(array $paths)
57+
public function __construct(array $paths, bool $reportFieldsWhereDeclared = false)
5758
{
5859
if (PHP_VERSION_ID < 80000) {
5960
throw new LogicException(sprintf(
@@ -73,6 +74,17 @@ public function __construct(array $paths)
7374
self::class
7475
);
7576
}
77+
78+
if (! $reportFieldsWhereDeclared) {
79+
Deprecation::trigger(
80+
'doctrine/orm',
81+
'https://github.com/doctrine/orm/pull/10455',
82+
'In ORM 3.0, the AttributeDriver will report fields for the classes where they are declared. This may uncover invalid mapping configurations. To opt into the new mode today, set the "reportFieldsWhereDeclared" constructor parameter to true.',
83+
self::class
84+
);
85+
}
86+
87+
$this->reportFieldsWhereDeclared = $reportFieldsWhereDeclared;
7688
}
7789

7890
/**
@@ -298,15 +310,8 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad
298310

299311
foreach ($reflectionClass->getProperties() as $property) {
300312
assert($property instanceof ReflectionProperty);
301-
if (
302-
$metadata->isMappedSuperclass && ! $property->isPrivate()
303-
||
304-
$metadata->isInheritedField($property->name)
305-
||
306-
$metadata->isInheritedAssociation($property->name)
307-
||
308-
$metadata->isInheritedEmbeddedClass($property->name)
309-
) {
313+
314+
if ($this->isRepeatedPropertyDeclaration($property, $metadata)) {
310315
continue;
311316
}
312317

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ORM\Mapping\Driver;
6+
7+
use Doctrine\ORM\Mapping\ClassMetadata;
8+
use ReflectionProperty;
9+
10+
/** @internal */
11+
trait ReflectionBasedDriver
12+
{
13+
/** @var bool */
14+
private $reportFieldsWhereDeclared = false;
15+
16+
/**
17+
* Helps to deal with the case that reflection may report properties inherited from parent classes.
18+
* When we know about the fields already (inheritance has been anticipated in ClassMetadataFactory),
19+
* the driver must skip them.
20+
*
21+
* The declaring classes may mismatch when there are private properties: The same property name may be
22+
* reported multiple times, but since it is private, it is in fact multiple (different) properties in
23+
* different classes. In that case, report the property as an individual field. (ClassMetadataFactory will
24+
* probably fail in that case, though.)
25+
*/
26+
private function isRepeatedPropertyDeclaration(ReflectionProperty $property, ClassMetadata $metadata): bool
27+
{
28+
if (! $this->reportFieldsWhereDeclared) {
29+
return $metadata->isMappedSuperclass && ! $property->isPrivate()
30+
|| $metadata->isInheritedField($property->name)
31+
|| $metadata->isInheritedAssociation($property->name)
32+
|| $metadata->isInheritedEmbeddedClass($property->name);
33+
}
34+
35+
$declaringClass = $property->getDeclaringClass()->getName();
36+
37+
if (
38+
isset($metadata->fieldMappings[$property->name]['declared'])
39+
&& $metadata->fieldMappings[$property->name]['declared'] === $declaringClass
40+
) {
41+
return true;
42+
}
43+
44+
if (
45+
isset($metadata->associationMappings[$property->name]['declared'])
46+
&& $metadata->associationMappings[$property->name]['declared'] === $declaringClass
47+
) {
48+
return true;
49+
}
50+
51+
return isset($metadata->embeddedClasses[$property->name]['declared'])
52+
&& $metadata->embeddedClasses[$property->name]['declared'] === $declaringClass;
53+
}
54+
}

lib/Doctrine/ORM/ORMSetup.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ public static function createAnnotationMetadataConfiguration(
6363
*/
6464
public static function createDefaultAnnotationDriver(
6565
array $paths = [],
66-
?CacheItemPoolInterface $cache = null
66+
?CacheItemPoolInterface $cache = null,
67+
bool $reportFieldsWhereDeclared = false
6768
): AnnotationDriver {
6869
Deprecation::trigger(
6970
'doctrine/orm',
@@ -89,7 +90,7 @@ public static function createDefaultAnnotationDriver(
8990
$reader = new PsrCachedReader($reader, $cache);
9091
}
9192

92-
return new AnnotationDriver($reader, $paths);
93+
return new AnnotationDriver($reader, $paths, $reportFieldsWhereDeclared);
9394
}
9495

9596
/**

tests/Doctrine/Tests/DoctrineTestCase.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ abstract class DoctrineTestCase extends TestCase
2121
'assertMatchesRegularExpression' => 'assertRegExp', // can be removed when PHPUnit 9 is minimum
2222
'assertDoesNotMatchRegularExpression' => 'assertNotRegExp', // can be removed when PHPUnit 9 is minimum
2323
'assertFileDoesNotExist' => 'assertFileNotExists', // can be removed PHPUnit 9 is minimum
24+
'expectExceptionMessageMatches' => 'expectExceptionMessageRegExp', // can be removed when PHPUnit 8 is minimum
2425
];
2526

2627
/**

tests/Doctrine/Tests/Models/Company/CompanyFlexContract.php

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
use Doctrine\ORM\Mapping\Entity;
1212
use Doctrine\ORM\Mapping\EntityResult;
1313
use Doctrine\ORM\Mapping\FieldResult;
14-
use Doctrine\ORM\Mapping\GeneratedValue;
15-
use Doctrine\ORM\Mapping\Id;
1614
use Doctrine\ORM\Mapping\JoinColumn;
1715
use Doctrine\ORM\Mapping\JoinTable;
1816
use Doctrine\ORM\Mapping\ManyToMany;
@@ -67,14 +65,6 @@
6765
#[ORM\Entity]
6866
class CompanyFlexContract extends CompanyContract
6967
{
70-
/**
71-
* @Id
72-
* @GeneratedValue
73-
* @Column(type="integer")
74-
* @var int
75-
*/
76-
public $id;
77-
7868
/**
7969
* @Column(type="integer")
8070
* @var int

tests/Doctrine/Tests/ORM/ConfigurationTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public function testNewDefaultAnnotationDriver(): void
101101
$paths = [__DIR__];
102102
$reflectionClass = new ReflectionClass(ConfigurationTestAnnotationReaderChecker::class);
103103

104-
$annotationDriver = $this->configuration->newDefaultAnnotationDriver($paths, false);
104+
$annotationDriver = $this->configuration->newDefaultAnnotationDriver($paths, false, true);
105105
$reader = $annotationDriver->getReader();
106106
$annotation = $reader->getMethodAnnotation(
107107
$reflectionClass->getMethod('namespacedAnnotationMethod'),

tests/Doctrine/Tests/ORM/Functional/EnumTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public function setUp(): void
3636
{
3737
parent::setUp();
3838

39-
$this->_em = $this->getEntityManager(null, new AttributeDriver([dirname(__DIR__, 2) . '/Models/Enums']));
39+
$this->_em = $this->getEntityManager(null, new AttributeDriver([dirname(__DIR__, 2) . '/Models/Enums'], true));
4040
$this->_schemaTool = new SchemaTool($this->_em);
4141

4242
if ($this->isSecondLevelCacheEnabled) {

tests/Doctrine/Tests/ORM/Functional/MergeProxiesTest.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,9 @@ private function createEntityManager(): EntityManagerInterface
242242

243243
TestUtil::configureProxies($config);
244244
$config->setMetadataDriverImpl(ORMSetup::createDefaultAnnotationDriver(
245-
[realpath(__DIR__ . '/../../Models/Cache')]
245+
[realpath(__DIR__ . '/../../Models/Cache')],
246+
null,
247+
true
246248
));
247249

248250
// always runs on sqlite to prevent multi-connection race-conditions with the test suite

tests/Doctrine/Tests/ORM/Functional/ReadonlyPropertiesTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ protected function setUp(): void
2626
}
2727

2828
$this->_em = $this->getEntityManager(null, new AttributeDriver(
29-
[dirname(__DIR__, 2) . '/Models/ReadonlyProperties']
29+
[dirname(__DIR__, 2) . '/Models/ReadonlyProperties'],
30+
true
3031
));
3132
$this->_schemaTool = new SchemaTool($this->_em);
3233

tests/Doctrine/Tests/ORM/Functional/Ticket/DDC719Test.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public function testIsEmptySqlGeneration(): void
3131
{
3232
$q = $this->_em->createQuery('SELECT g, c FROM Doctrine\Tests\ORM\Functional\Ticket\DDC719Group g LEFT JOIN g.children c WHERE g.parents IS EMPTY');
3333

34-
$referenceSQL = 'SELECT g0_.name AS name_0, g0_.description AS description_1, g0_.id AS id_2, g1_.name AS name_3, g1_.description AS description_4, g1_.id AS id_5 FROM groups g0_ LEFT JOIN groups_groups g2_ ON g0_.id = g2_.parent_id LEFT JOIN groups g1_ ON g1_.id = g2_.child_id WHERE (SELECT COUNT(*) FROM groups_groups g3_ WHERE g3_.child_id = g0_.id) = 0';
34+
$referenceSQL = 'SELECT g0_.id AS id_0, g0_.name AS name_1, g0_.description AS description_2, g1_.id as id_3, g1_.name AS name_4, g1_.description AS description_5 FROM groups g0_ LEFT JOIN groups_groups g2_ ON g0_.id = g2_.parent_id LEFT JOIN groups g1_ ON g1_.id = g2_.child_id WHERE (SELECT COUNT(*) FROM groups_groups g3_ WHERE g3_.child_id = g0_.id) = 0';
3535

3636
self::assertEquals(
3737
strtolower($referenceSQL),

0 commit comments

Comments
 (0)