From a9873c86bb0c7a2efb0bb42b3227a9963be215d8 Mon Sep 17 00:00:00 2001 From: Niels Keurentjes Date: Fri, 20 Dec 2024 16:03:56 +0100 Subject: [PATCH 1/5] Take hardcoded reference column name out of JoinColumn attribute Previously, when using a custom naming strategy, explicitly declaring a JoinColumn required specifying the referencedColumnName always as it would default to id no matter the naming strategy. This PR changes it to be determines correctly. Ref #9558 --- src/Mapping/JoinColumnMapping.php | 2 +- src/Mapping/JoinColumnProperties.php | 2 +- src/Mapping/ManyToManyOwningSideMapping.php | 10 +- src/Mapping/ToOneOwningSideMapping.php | 3 + .../ORM/Mapping/MappingDriverTestCase.php | 57 ++++++++++ .../CustomPascalNamingStrategy.php | 101 ++++++++++++++++++ .../Tests/ORM/Mapping/NamingStrategyTest.php | 17 +++ ...octrine.Tests.ORM.Mapping.BlogPost.dcm.xml | 16 +++ ....Tests.ORM.Mapping.BlogPostComment.dcm.xml | 22 ++++ 9 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 tests/Tests/ORM/Mapping/NamingStrategy/CustomPascalNamingStrategy.php create mode 100644 tests/Tests/ORM/Mapping/xml/Doctrine.Tests.ORM.Mapping.BlogPost.dcm.xml create mode 100644 tests/Tests/ORM/Mapping/xml/Doctrine.Tests.ORM.Mapping.BlogPostComment.dcm.xml diff --git a/src/Mapping/JoinColumnMapping.php b/src/Mapping/JoinColumnMapping.php index cb54b196a99..86ea1dc09c8 100644 --- a/src/Mapping/JoinColumnMapping.php +++ b/src/Mapping/JoinColumnMapping.php @@ -33,7 +33,7 @@ public function __construct( * @param array $mappingArray * @phpstan-param array{ * name: string, - * referencedColumnName: string, + * referencedColumnName: string|null, * unique?: bool|null, * quoted?: bool|null, * fieldName?: string|null, diff --git a/src/Mapping/JoinColumnProperties.php b/src/Mapping/JoinColumnProperties.php index 7d132952b31..b231d834d79 100644 --- a/src/Mapping/JoinColumnProperties.php +++ b/src/Mapping/JoinColumnProperties.php @@ -9,7 +9,7 @@ trait JoinColumnProperties /** @param array $options */ public function __construct( public readonly string|null $name = null, - public readonly string $referencedColumnName = 'id', + public readonly string|null $referencedColumnName = null, public readonly bool $unique = false, public readonly bool $nullable = true, public readonly mixed $onDelete = null, diff --git a/src/Mapping/ManyToManyOwningSideMapping.php b/src/Mapping/ManyToManyOwningSideMapping.php index d8abaedaeef..3031e749c32 100644 --- a/src/Mapping/ManyToManyOwningSideMapping.php +++ b/src/Mapping/ManyToManyOwningSideMapping.php @@ -61,10 +61,13 @@ public static function fromMappingArrayAndNamingStrategy(array $mappingArray, Na { if (isset($mappingArray['joinTable']['joinColumns'])) { foreach ($mappingArray['joinTable']['joinColumns'] as $key => $joinColumn) { + if (empty($joinColumn['referencedColumnName'])) { + $mappingArray['joinTable']['joinColumns'][$key]['referencedColumnName'] = $namingStrategy->referenceColumnName(); + } if (empty($joinColumn['name'])) { $mappingArray['joinTable']['joinColumns'][$key]['name'] = $namingStrategy->joinKeyColumnName( $mappingArray['sourceEntity'], - $joinColumn['referencedColumnName'] ?? null, + $joinColumn['referencedColumnName'] ?? $namingStrategy->referenceColumnName(), ); } } @@ -72,10 +75,13 @@ public static function fromMappingArrayAndNamingStrategy(array $mappingArray, Na if (isset($mappingArray['joinTable']['inverseJoinColumns'])) { foreach ($mappingArray['joinTable']['inverseJoinColumns'] as $key => $joinColumn) { + if (empty($joinColumn['referencedColumnName'])) { + $mappingArray['joinTable']['inverseJoinColumns'][$key]['referencedColumnName'] = $namingStrategy->referenceColumnName(); + } if (empty($joinColumn['name'])) { $mappingArray['joinTable']['inverseJoinColumns'][$key]['name'] = $namingStrategy->joinKeyColumnName( $mappingArray['targetEntity'], - $joinColumn['referencedColumnName'] ?? null, + $joinColumn['referencedColumnName'] ?? $namingStrategy->referenceColumnName(), ); } } diff --git a/src/Mapping/ToOneOwningSideMapping.php b/src/Mapping/ToOneOwningSideMapping.php index ed3d596f801..57ed66e6a97 100644 --- a/src/Mapping/ToOneOwningSideMapping.php +++ b/src/Mapping/ToOneOwningSideMapping.php @@ -107,6 +107,9 @@ public static function fromMappingArrayAndName( if (empty($joinColumn['name'])) { $mappingArray['joinColumns'][$index]['name'] = $namingStrategy->joinColumnName($mappingArray['fieldName'], $name); } + if (empty($joinColumn['referencedColumnName'])) { + $mappingArray['joinColumns'][$index]['referencedColumnName'] = $namingStrategy->referenceColumnName(); + } } } diff --git a/tests/Tests/ORM/Mapping/MappingDriverTestCase.php b/tests/Tests/ORM/Mapping/MappingDriverTestCase.php index 10d9d4ba965..1f91a02de89 100644 --- a/tests/Tests/ORM/Mapping/MappingDriverTestCase.php +++ b/tests/Tests/ORM/Mapping/MappingDriverTestCase.php @@ -62,6 +62,7 @@ use Doctrine\Tests\Models\TypedProperties\UserTypedWithCustomTypedField; use Doctrine\Tests\Models\Upsertable\Insertable; use Doctrine\Tests\Models\Upsertable\Updatable; +use Doctrine\Tests\ORM\Mapping\NamingStrategy\CustomPascalNamingStrategy; use Doctrine\Tests\OrmTestCase; use PHPUnit\Framework\Attributes\Depends; use stdClass; @@ -946,6 +947,16 @@ public function testEnumType(): void self::assertEquals(Suit::class, $metadata->fieldMappings['suit']->enumType); } + + public function testCustomNamingStrategyIsRespected(): void + { + $ns = new CustomPascalNamingStrategy(); + $metadata = $this->createClassMetadata(BlogPostComment::class, $ns); + + self::assertEquals('id', $metadata->fieldNames['Id']); + self::assertEquals('Id', $metadata->associationMappings['blogPost']->joinColumns[0]->referencedColumnName); + self::assertFalse($metadata->associationMappings['blogPost']->joinColumns[0]->nullable); + } } #[ORM\Entity()] @@ -1547,3 +1558,49 @@ public static function loadMetadata(ClassMetadata $metadata): void class GH10288EnumTypeBoss extends GH10288EnumTypePerson { } + +/** + * Two small related entities to test default namings with barebone attributes + */ +#[Entity] +class BlogPost +{ + #[Id, Column, GeneratedValue(strategy: 'NONE')] + public int $id; +} + +#[Entity] +class BlogPostComment +{ + #[Id, Column, GeneratedValue(strategy: 'AUTO')] + public int $id; + + #[ORM\ManyToOne, ORM\JoinColumn(nullable: false)] + public BlogPost $blogPost; + + public static function loadMetadata(ClassMetadata $metadata): void + { + $metadata->mapField( + [ + 'id' => true, + 'fieldName' => 'id', + 'type' => 'integer', + ], + ); + $metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO); + + $metadata->mapManyToOne( + [ + 'fieldName' => 'blogPost', + 'targetEntity' => BlogPost::class, + 'joinColumns' => + [ + 0 => + [ + 'nullable' => false, + ], + ], + ] + ); + } +} diff --git a/tests/Tests/ORM/Mapping/NamingStrategy/CustomPascalNamingStrategy.php b/tests/Tests/ORM/Mapping/NamingStrategy/CustomPascalNamingStrategy.php new file mode 100644 index 00000000000..0936b45cd01 --- /dev/null +++ b/tests/Tests/ORM/Mapping/NamingStrategy/CustomPascalNamingStrategy.php @@ -0,0 +1,101 @@ +classToTableName($className)) . 'id') { + return 'Id'; + } + + return ucfirst($propertyName); + } + + /** + * Returns a column name for an embedded property. + */ + public function embeddedFieldToColumnName(string $propertyName, string $embeddedColumnName, ?string $className = null, $embeddedClassName = null): string + { + throw new \LogicException(sprintf('Method %s is not implemented', __METHOD__)); + } + + /** + * Returns the default reference column name. + * + * @return string A column name + */ + public function referenceColumnName(): string + { + return 'Id'; + } + + /** + * Returns a join column name for a property. + * + * @return string A join column name + */ + public function joinColumnName(string $propertyName, string $className): string + { + return ucfirst($propertyName) . $this->referenceColumnName(); + } + + /** + * Returns a join table name. + * + * @param string $sourceEntity The source entity + * @param string $targetEntity The target entity + * @param string|null $propertyName A property name + * + * @return string A join table name + */ + public function joinTableName(string $sourceEntity, string $targetEntity, ?string $propertyName = null): string + { + return $this->classToTableName($sourceEntity) . $this->classToTableName($targetEntity); + } + + /** + * Returns the foreign key column name for the given parameters. + * + * @param string $entityName An entity + * @param string|null $referencedColumnName A property + * + * @return string A join column name + */ + public function joinKeyColumnName(string $entityName, ?string $referencedColumnName = null): string + { + return $this->classToTableName($entityName) . ($referencedColumnName ?: $this->referenceColumnName()); + } +} diff --git a/tests/Tests/ORM/Mapping/NamingStrategyTest.php b/tests/Tests/ORM/Mapping/NamingStrategyTest.php index ecade647392..18ee29fbe4c 100644 --- a/tests/Tests/ORM/Mapping/NamingStrategyTest.php +++ b/tests/Tests/ORM/Mapping/NamingStrategyTest.php @@ -7,6 +7,7 @@ use Doctrine\ORM\Mapping\DefaultNamingStrategy; use Doctrine\ORM\Mapping\NamingStrategy; use Doctrine\ORM\Mapping\UnderscoreNamingStrategy; +use Doctrine\Tests\ORM\Mapping\NamingStrategy\CustomPascalNamingStrategy; use Doctrine\Tests\ORM\Mapping\NamingStrategy\JoinColumnClassNamingStrategy; use Doctrine\Tests\OrmTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -33,6 +34,11 @@ private static function underscoreNamingUpper(): UnderscoreNamingStrategy return new UnderscoreNamingStrategy(CASE_UPPER); } + private static function customNaming(): CustomPascalNamingStrategy + { + return new CustomPascalNamingStrategy(); + } + /** * Data Provider for NamingStrategy#classToTableName * @@ -56,6 +62,10 @@ public static function dataClassToTableName(): array [self::underscoreNamingUpper(), 'NAME', '\Some\Class\Name'], [self::underscoreNamingUpper(), 'NAME2_TEST', '\Some\Class\Name2Test'], [self::underscoreNamingUpper(), 'NAME2TEST', '\Some\Class\Name2test'], + + // CustomPascalNamingStrategy + [self::customNaming(), 'SomeClassName', 'SomeClassName'], + [self::customNaming(), 'Name2Test', '\Some\Class\Name2Test'], ]; } @@ -89,6 +99,10 @@ public static function dataPropertyToColumnName(): array [self::underscoreNamingUpper(), 'SOME_PROPERTY', 'SOME_PROPERTY', 'Some\Class'], [self::underscoreNamingUpper(), 'BASE64_ENCODED', 'base64Encoded', 'Some\Class'], [self::underscoreNamingUpper(), 'BASE64ENCODED', 'base64encoded', 'Some\Class'], + + // CustomPascalNamingStrategy + [self::customNaming(), 'SomeProperty', 'someProperty', 'Some\Class'], + [self::customNaming(), 'Base64Encoded', 'base64Encoded', 'Some\Class'], ]; } @@ -116,6 +130,9 @@ public static function dataReferenceColumnName(): array // UnderscoreNamingStrategy [self::underscoreNamingLower(), 'id'], [self::underscoreNamingUpper(), 'ID'], + + // CustomPascalNamingStrategy + [self::customNaming(), 'Id'], ]; } diff --git a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.ORM.Mapping.BlogPost.dcm.xml b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.ORM.Mapping.BlogPost.dcm.xml new file mode 100644 index 00000000000..48eda68df15 --- /dev/null +++ b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.ORM.Mapping.BlogPost.dcm.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.ORM.Mapping.BlogPostComment.dcm.xml b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.ORM.Mapping.BlogPostComment.dcm.xml new file mode 100644 index 00000000000..58c63565296 --- /dev/null +++ b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.ORM.Mapping.BlogPostComment.dcm.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + From 0d2cb6acd184ede53949f77f8c365bf6132e6873 Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Tue, 7 Jan 2025 09:53:43 +0100 Subject: [PATCH 2/5] Fix CS (#11782) --- src/Mapping/ManyToManyOwningSideMapping.php | 2 ++ src/Mapping/ToOneOwningSideMapping.php | 1 + .../ORM/Mapping/MappingDriverTestCase.php | 20 +++++++------- .../CustomPascalNamingStrategy.php | 27 ++++++++++++------- 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/Mapping/ManyToManyOwningSideMapping.php b/src/Mapping/ManyToManyOwningSideMapping.php index 3031e749c32..b4ca899a8dc 100644 --- a/src/Mapping/ManyToManyOwningSideMapping.php +++ b/src/Mapping/ManyToManyOwningSideMapping.php @@ -64,6 +64,7 @@ public static function fromMappingArrayAndNamingStrategy(array $mappingArray, Na if (empty($joinColumn['referencedColumnName'])) { $mappingArray['joinTable']['joinColumns'][$key]['referencedColumnName'] = $namingStrategy->referenceColumnName(); } + if (empty($joinColumn['name'])) { $mappingArray['joinTable']['joinColumns'][$key]['name'] = $namingStrategy->joinKeyColumnName( $mappingArray['sourceEntity'], @@ -78,6 +79,7 @@ public static function fromMappingArrayAndNamingStrategy(array $mappingArray, Na if (empty($joinColumn['referencedColumnName'])) { $mappingArray['joinTable']['inverseJoinColumns'][$key]['referencedColumnName'] = $namingStrategy->referenceColumnName(); } + if (empty($joinColumn['name'])) { $mappingArray['joinTable']['inverseJoinColumns'][$key]['name'] = $namingStrategy->joinKeyColumnName( $mappingArray['targetEntity'], diff --git a/src/Mapping/ToOneOwningSideMapping.php b/src/Mapping/ToOneOwningSideMapping.php index 57ed66e6a97..718679f688d 100644 --- a/src/Mapping/ToOneOwningSideMapping.php +++ b/src/Mapping/ToOneOwningSideMapping.php @@ -107,6 +107,7 @@ public static function fromMappingArrayAndName( if (empty($joinColumn['name'])) { $mappingArray['joinColumns'][$index]['name'] = $namingStrategy->joinColumnName($mappingArray['fieldName'], $name); } + if (empty($joinColumn['referencedColumnName'])) { $mappingArray['joinColumns'][$index]['referencedColumnName'] = $namingStrategy->referenceColumnName(); } diff --git a/tests/Tests/ORM/Mapping/MappingDriverTestCase.php b/tests/Tests/ORM/Mapping/MappingDriverTestCase.php index 1f91a02de89..33b409f7f57 100644 --- a/tests/Tests/ORM/Mapping/MappingDriverTestCase.php +++ b/tests/Tests/ORM/Mapping/MappingDriverTestCase.php @@ -950,7 +950,7 @@ public function testEnumType(): void public function testCustomNamingStrategyIsRespected(): void { - $ns = new CustomPascalNamingStrategy(); + $ns = new CustomPascalNamingStrategy(); $metadata = $this->createClassMetadata(BlogPostComment::class, $ns); self::assertEquals('id', $metadata->fieldNames['Id']); @@ -1565,17 +1565,22 @@ class GH10288EnumTypeBoss extends GH10288EnumTypePerson #[Entity] class BlogPost { - #[Id, Column, GeneratedValue(strategy: 'NONE')] + #[Id] + #[Column] + #[GeneratedValue(strategy: 'NONE')] public int $id; } #[Entity] class BlogPostComment { - #[Id, Column, GeneratedValue(strategy: 'AUTO')] + #[Id] + #[Column] + #[GeneratedValue(strategy: 'AUTO')] public int $id; - #[ORM\ManyToOne, ORM\JoinColumn(nullable: false)] + #[ORM\ManyToOne] + #[ORM\JoinColumn(nullable: false)] public BlogPost $blogPost; public static function loadMetadata(ClassMetadata $metadata): void @@ -1595,12 +1600,9 @@ public static function loadMetadata(ClassMetadata $metadata): void 'targetEntity' => BlogPost::class, 'joinColumns' => [ - 0 => - [ - 'nullable' => false, - ], + 0 => ['nullable' => false], ], - ] + ], ); } } diff --git a/tests/Tests/ORM/Mapping/NamingStrategy/CustomPascalNamingStrategy.php b/tests/Tests/ORM/Mapping/NamingStrategy/CustomPascalNamingStrategy.php index 0936b45cd01..bb7abd47b21 100644 --- a/tests/Tests/ORM/Mapping/NamingStrategy/CustomPascalNamingStrategy.php +++ b/tests/Tests/ORM/Mapping/NamingStrategy/CustomPascalNamingStrategy.php @@ -5,6 +5,14 @@ namespace Doctrine\Tests\ORM\Mapping\NamingStrategy; use Doctrine\ORM\Mapping\NamingStrategy; +use LogicException; + +use function sprintf; +use function str_contains; +use function strrpos; +use function strtolower; +use function substr; +use function ucfirst; /** * Fully customized naming strategy changing all namings to a PascalCase model. Included to test some behaviours @@ -16,6 +24,7 @@ class CustomPascalNamingStrategy implements NamingStrategy * Returns a table name for an entity class. * * @param string $className The fully-qualified class name + * * @return string A table name */ public function classToTableName(string $className): string @@ -30,14 +39,14 @@ public function classToTableName(string $className): string /** * Returns a column name for a property. * - * @param string $propertyName A property name + * @param string $propertyName A property name * @param string|null $className The fully-qualified class name * * @return string A column name */ - public function propertyToColumnName(string $propertyName, ?string $className = null): string + public function propertyToColumnName(string $propertyName, string|null $className = null): string { - if (null !== $className && strtolower($propertyName) == strtolower($this->classToTableName($className)) . 'id') { + if ($className !== null && strtolower($propertyName) === strtolower($this->classToTableName($className)) . 'id') { return 'Id'; } @@ -47,9 +56,9 @@ public function propertyToColumnName(string $propertyName, ?string $className = /** * Returns a column name for an embedded property. */ - public function embeddedFieldToColumnName(string $propertyName, string $embeddedColumnName, ?string $className = null, $embeddedClassName = null): string + public function embeddedFieldToColumnName(string $propertyName, string $embeddedColumnName, string|null $className = null, $embeddedClassName = null): string { - throw new \LogicException(sprintf('Method %s is not implemented', __METHOD__)); + throw new LogicException(sprintf('Method %s is not implemented', __METHOD__)); } /** @@ -75,13 +84,13 @@ public function joinColumnName(string $propertyName, string $className): string /** * Returns a join table name. * - * @param string $sourceEntity The source entity - * @param string $targetEntity The target entity + * @param string $sourceEntity The source entity + * @param string $targetEntity The target entity * @param string|null $propertyName A property name * * @return string A join table name */ - public function joinTableName(string $sourceEntity, string $targetEntity, ?string $propertyName = null): string + public function joinTableName(string $sourceEntity, string $targetEntity, string|null $propertyName = null): string { return $this->classToTableName($sourceEntity) . $this->classToTableName($targetEntity); } @@ -94,7 +103,7 @@ public function joinTableName(string $sourceEntity, string $targetEntity, ?strin * * @return string A join column name */ - public function joinKeyColumnName(string $entityName, ?string $referencedColumnName = null): string + public function joinKeyColumnName(string $entityName, string|null $referencedColumnName = null): string { return $this->classToTableName($entityName) . ($referencedColumnName ?: $this->referenceColumnName()); } From d7ac6123adf308af5698c4bcfdef7110de7546d5 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 28 Jan 2025 19:17:28 +0100 Subject: [PATCH 3/5] Expose QueryType --- src/QueryBuilder.php | 6 +++++- src/{Internal => }/QueryType.php | 3 +-- tests/Tests/ORM/QueryBuilderTest.php | 23 +++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) rename src/{Internal => }/QueryType.php (53%) diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index d695501ba6c..6c7d65a0fc7 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -9,7 +9,6 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\ParameterType; use Doctrine\ORM\Internal\NoUnknownNamedArguments; -use Doctrine\ORM\Internal\QueryType; use Doctrine\ORM\Query\Expr; use Doctrine\ORM\Query\Parameter; use Doctrine\ORM\Query\QueryExpressionVisitor; @@ -128,6 +127,11 @@ public function __construct( $this->parameters = new ArrayCollection(); } + final protected function getType(): QueryType + { + return $this->type; + } + /** * Gets an ExpressionBuilder used for object-oriented construction of query expressions. * This producer method is intended for convenient inline usage. Example: diff --git a/src/Internal/QueryType.php b/src/QueryType.php similarity index 53% rename from src/Internal/QueryType.php rename to src/QueryType.php index b5e60c7e828..209bf56e753 100644 --- a/src/Internal/QueryType.php +++ b/src/QueryType.php @@ -2,9 +2,8 @@ declare(strict_types=1); -namespace Doctrine\ORM\Internal; +namespace Doctrine\ORM; -/** @internal To be used inside the QueryBuilder only. */ enum QueryType { case Select; diff --git a/tests/Tests/ORM/QueryBuilderTest.php b/tests/Tests/ORM/QueryBuilderTest.php index fd610bced44..be93ffd7e1c 100644 --- a/tests/Tests/ORM/QueryBuilderTest.php +++ b/tests/Tests/ORM/QueryBuilderTest.php @@ -15,6 +15,7 @@ use Doctrine\ORM\Query\Parameter; use Doctrine\ORM\Query\ParameterTypeInferer; use Doctrine\ORM\QueryBuilder; +use Doctrine\ORM\QueryType; use Doctrine\Tests\Mocks\EntityManagerMock; use Doctrine\Tests\Models\Cache\State; use Doctrine\Tests\Models\CMS\CmsArticle; @@ -23,6 +24,7 @@ use Doctrine\Tests\OrmTestCase; use InvalidArgumentException; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; use function array_filter; use function class_exists; @@ -1326,4 +1328,25 @@ public function testCreateNamedParameterCustomPlaceholder(): void self::assertEquals(100, $qb->getParameter('dcValue1')->getValue()); self::assertEquals(Types::INTEGER, $qb->getParameter('dcValue1')->getType()); } + + public function testType(): void + { + $qb = new class ($this->entityManager) extends QueryBuilder { + public function test(): void + { + TestCase::assertSame(QueryType::Select, $this->getType()); + + $this->delete(); + TestCase::assertSame(QueryType::Delete, $this->getType()); + + $this->update(); + TestCase::assertSame(QueryType::Update, $this->getType()); + + $this->select(); + TestCase::assertSame(QueryType::Select, $this->getType()); + } + }; + + $qb->test(); + } } From cc88407adfc36bacf473f50880a77fdb29dd4fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= Date: Sat, 25 Jan 2025 12:17:06 +0100 Subject: [PATCH 4/5] Address quoteIdentifier() deprecation We should be using quoteSingleIdentifier(), assuming we only ever pass single identifiers here. See https://github.com/doctrine/dbal/pull/6590 --- src/Mapping/DefaultQuoteStrategy.php | 14 +++++++------- .../Tests/ORM/Functional/Ticket/DDC832Test.php | 10 +++++----- tests/Tests/OrmFunctionalTestCase.php | 18 +++++++++--------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Mapping/DefaultQuoteStrategy.php b/src/Mapping/DefaultQuoteStrategy.php index 6260336c027..b42de4e862b 100644 --- a/src/Mapping/DefaultQuoteStrategy.php +++ b/src/Mapping/DefaultQuoteStrategy.php @@ -24,7 +24,7 @@ class DefaultQuoteStrategy implements QuoteStrategy public function getColumnName(string $fieldName, ClassMetadata $class, AbstractPlatform $platform): string { return isset($class->fieldMappings[$fieldName]->quoted) - ? $platform->quoteIdentifier($class->fieldMappings[$fieldName]->columnName) + ? $platform->quoteSingleIdentifier($class->fieldMappings[$fieldName]->columnName) : $class->fieldMappings[$fieldName]->columnName; } @@ -42,7 +42,7 @@ public function getTableName(ClassMetadata $class, AbstractPlatform $platform): } return isset($class->table['quoted']) - ? $platform->quoteIdentifier($tableName) + ? $platform->quoteSingleIdentifier($tableName) : $tableName; } @@ -52,14 +52,14 @@ public function getTableName(ClassMetadata $class, AbstractPlatform $platform): public function getSequenceName(array $definition, ClassMetadata $class, AbstractPlatform $platform): string { return isset($definition['quoted']) - ? $platform->quoteIdentifier($definition['sequenceName']) + ? $platform->quoteSingleIdentifier($definition['sequenceName']) : $definition['sequenceName']; } public function getJoinColumnName(JoinColumnMapping $joinColumn, ClassMetadata $class, AbstractPlatform $platform): string { return isset($joinColumn->quoted) - ? $platform->quoteIdentifier($joinColumn->name) + ? $platform->quoteSingleIdentifier($joinColumn->name) : $joinColumn->name; } @@ -69,7 +69,7 @@ public function getReferencedJoinColumnName( AbstractPlatform $platform, ): string { return isset($joinColumn->quoted) - ? $platform->quoteIdentifier($joinColumn->referencedColumnName) + ? $platform->quoteSingleIdentifier($joinColumn->referencedColumnName) : $joinColumn->referencedColumnName; } @@ -87,7 +87,7 @@ public function getJoinTableName( $tableName = $association->joinTable->name; if (isset($association->joinTable->quoted)) { - $tableName = $platform->quoteIdentifier($tableName); + $tableName = $platform->quoteSingleIdentifier($tableName); } return $schema . $tableName; @@ -113,7 +113,7 @@ public function getIdentifierColumnNames(ClassMetadata $class, AbstractPlatform $joinColumns = $assoc->joinColumns; $assocQuotedColumnNames = array_map( static fn (JoinColumnMapping $joinColumn) => isset($joinColumn->quoted) - ? $platform->quoteIdentifier($joinColumn->name) + ? $platform->quoteSingleIdentifier($joinColumn->name) : $joinColumn->name, $joinColumns, ); diff --git a/tests/Tests/ORM/Functional/Ticket/DDC832Test.php b/tests/Tests/ORM/Functional/Ticket/DDC832Test.php index ca7f948518e..834c7a5cf5a 100644 --- a/tests/Tests/ORM/Functional/Ticket/DDC832Test.php +++ b/tests/Tests/ORM/Functional/Ticket/DDC832Test.php @@ -42,14 +42,14 @@ public function tearDown(): void $platform = $this->_em->getConnection()->getDatabasePlatform(); $sm = $this->createSchemaManager(); - $sm->dropTable($platform->quoteIdentifier('TREE_INDEX')); - $sm->dropTable($platform->quoteIdentifier('INDEX')); - $sm->dropTable($platform->quoteIdentifier('LIKE')); + $sm->dropTable($platform->quoteSingleIdentifier('TREE_INDEX')); + $sm->dropTable($platform->quoteSingleIdentifier('INDEX')); + $sm->dropTable($platform->quoteSingleIdentifier('LIKE')); // DBAL 3 if ($platform instanceof PostgreSQLPlatform && method_exists($platform, 'getIdentitySequenceName')) { - $sm->dropSequence($platform->quoteIdentifier('INDEX_id_seq')); - $sm->dropSequence($platform->quoteIdentifier('LIKE_id_seq')); + $sm->dropSequence($platform->quoteSingleIdentifier('INDEX_id_seq')); + $sm->dropSequence($platform->quoteSingleIdentifier('LIKE_id_seq')); } } diff --git a/tests/Tests/OrmFunctionalTestCase.php b/tests/Tests/OrmFunctionalTestCase.php index f2cf5f81f18..efd380dc1de 100644 --- a/tests/Tests/OrmFunctionalTestCase.php +++ b/tests/Tests/OrmFunctionalTestCase.php @@ -614,7 +614,7 @@ protected function tearDown(): void } if (isset($this->_usedModelSets['directorytree'])) { - $conn->executeStatement('DELETE FROM ' . $platform->quoteIdentifier('file')); + $conn->executeStatement('DELETE FROM ' . $platform->quoteSingleIdentifier('file')); // MySQL doesn't know deferred deletions therefore only executing the second query gives errors. $conn->executeStatement('DELETE FROM Directory WHERE parentDirectory_id IS NOT NULL'); $conn->executeStatement('DELETE FROM Directory'); @@ -707,17 +707,17 @@ protected function tearDown(): void $conn->executeStatement( sprintf( 'UPDATE %s SET %s = NULL', - $platform->quoteIdentifier('quote-address'), - $platform->quoteIdentifier('user-id'), + $platform->quoteSingleIdentifier('quote-address'), + $platform->quoteSingleIdentifier('user-id'), ), ); - $conn->executeStatement('DELETE FROM ' . $platform->quoteIdentifier('quote-users-groups')); - $conn->executeStatement('DELETE FROM ' . $platform->quoteIdentifier('quote-group')); - $conn->executeStatement('DELETE FROM ' . $platform->quoteIdentifier('quote-phone')); - $conn->executeStatement('DELETE FROM ' . $platform->quoteIdentifier('quote-user')); - $conn->executeStatement('DELETE FROM ' . $platform->quoteIdentifier('quote-address')); - $conn->executeStatement('DELETE FROM ' . $platform->quoteIdentifier('quote-city')); + $conn->executeStatement('DELETE FROM ' . $platform->quoteSingleIdentifier('quote-users-groups')); + $conn->executeStatement('DELETE FROM ' . $platform->quoteSingleIdentifier('quote-group')); + $conn->executeStatement('DELETE FROM ' . $platform->quoteSingleIdentifier('quote-phone')); + $conn->executeStatement('DELETE FROM ' . $platform->quoteSingleIdentifier('quote-user')); + $conn->executeStatement('DELETE FROM ' . $platform->quoteSingleIdentifier('quote-address')); + $conn->executeStatement('DELETE FROM ' . $platform->quoteSingleIdentifier('quote-city')); } if (isset($this->_usedModelSets['vct_onetoone'])) { From 1795e6116147c0a91739d5d49a9965e96a7b0a68 Mon Sep 17 00:00:00 2001 From: Rixafy <45132928+Rixafy@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:32:28 +0100 Subject: [PATCH 5/5] Add support for inherited nullability from PHP --- docs/en/reference/advanced-configuration.rst | 18 ++++ docs/en/reference/attributes-reference.rst | 4 +- docs/en/reference/xml-mapping.rst | 3 +- phpstan-baseline.neon | 2 +- src/Configuration.php | 10 ++ src/Mapping/ClassMetadata.php | 40 ++++++-- src/Mapping/ClassMetadataFactory.php | 1 + src/Mapping/Column.php | 7 +- src/Mapping/Driver/AttributeDriver.php | 16 +-- src/Mapping/JoinColumnProperties.php | 7 +- src/ORMSetup.php | 2 +- .../Tests/Models/TypedProperties/Contact.php | 6 ++ .../Models/TypedProperties/UserTyped.php | 84 +++++++++++++++- .../ORM/Mapping/MappingDriverTestCase.php | 97 ++++++++++++++++++- ...sts.Models.TypedProperties.Contact.dcm.xml | 11 +++ ...s.Models.TypedProperties.UserTyped.dcm.xml | 21 +++- 16 files changed, 302 insertions(+), 27 deletions(-) create mode 100644 tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml diff --git a/docs/en/reference/advanced-configuration.rst b/docs/en/reference/advanced-configuration.rst index 3282bbdb359..ee6c859aefa 100644 --- a/docs/en/reference/advanced-configuration.rst +++ b/docs/en/reference/advanced-configuration.rst @@ -184,6 +184,24 @@ For development you should use an array cache like ``Symfony\Component\Cache\Adapter\ArrayAdapter`` which only caches data on a per-request basis. +Nullability detection (***RECOMMENDED***) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. note:: + + Since ORM 3.4.0 + +.. code-block:: php + + setInferPhpNullability(true); + +Sets whether Doctrine should infer the nullability of PHP types to the +database schema. This is useful when using PHP 7.4+ typed properties + +You can always override the inferred nullability by specifying the +``nullable`` option in the Column or JoinColumn definition. + SQL Logger (***Optional***) ~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index d3a1eb04674..14505b1390f 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -176,7 +176,8 @@ Optional parameters: should be unique across all rows of the underlying entities table. - **nullable**: Determines if NULL values allowed for this column. - If not specified, default value is ``false``. + If not specified, default value is ``false``. + Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``. - **insertable**: Boolean value to determine if the column should be included when inserting a new row into the underlying entities table. @@ -674,6 +675,7 @@ Optional parameters: constraint level. Defaults to false. - **nullable**: Determine whether the related entity is required, or if null is an allowed state for the relation. Defaults to true. + Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``. - **onDelete**: Cascade Action (Database-level) - **columnDefinition**: DDL SQL snippet that starts after the column name and specifies the complete (non-portable!) column definition. diff --git a/docs/en/reference/xml-mapping.rst b/docs/en/reference/xml-mapping.rst index e010c93690b..b00d7f975bb 100644 --- a/docs/en/reference/xml-mapping.rst +++ b/docs/en/reference/xml-mapping.rst @@ -256,7 +256,7 @@ Optional attributes: - unique - Should this field contain a unique value across the table? Defaults to false. - nullable - Should this field allow NULL as a value? Defaults to - false. + false. Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``. - insertable - Should this field be inserted? Defaults to true. - updatable - Should this field be updated? Defaults to true. - generated - Enum of the values ALWAYS, INSERT, NEVER that determines if @@ -717,6 +717,7 @@ Optional attributes: This makes sense for Many-To-Many join-columns only to simulate a one-to-many unidirectional using a join-table. - nullable - should the join column be nullable, defaults to true. + Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``. - on-delete - Foreign Key Cascade action to perform when entity is deleted, defaults to NO ACTION/RESTRICT but can be set to "CASCADE". diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 44cb4153e21..cf80738f6b1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -991,7 +991,7 @@ parameters: path: src/Mapping/ClassMetadata.php - - message: '#^Parameter \#1 \$mapping of method Doctrine\\ORM\\Mapping\\ClassMetadata\\:\:validateAndCompleteTypedAssociationMapping\(\) expects array\{type\: 1\|2\|4\|8, fieldName\: string, targetEntity\?\: class\-string\}, non\-empty\-array\ given\.$#' + message: '#^Parameter \#1 \$mapping of method Doctrine\\ORM\\Mapping\\ClassMetadata\\:\:validateAndCompleteTypedAssociationMapping\(\) expects array\{type\: 1\|2\|4\|8, fieldName\: string, targetEntity\?\: class\-string, joinColumns\: array\\>\|null\}, non\-empty\-array\ given\.$#' identifier: argument.type count: 1 path: src/Mapping/ClassMetadata.php diff --git a/src/Configuration.php b/src/Configuration.php index 361d146a50b..7614267d79c 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -644,4 +644,14 @@ public function getEagerFetchBatchSize(): int { return $this->attributes['fetchModeSubselectBatchSize'] ?? 100; } + + public function setInferPhpNullability(bool $inferPhpNullability): void + { + $this->attributes['inferPhpNullability'] = $inferPhpNullability; + } + + public function isPhpNullabilityInferred(): bool + { + return $this->attributes['inferPhpNullability'] ?? false; + } } diff --git a/src/Mapping/ClassMetadata.php b/src/Mapping/ClassMetadata.php index 7351d09bce0..69d17223842 100644 --- a/src/Mapping/ClassMetadata.php +++ b/src/Mapping/ClassMetadata.php @@ -22,6 +22,7 @@ use ReflectionClass; use ReflectionNamedType; use ReflectionProperty; +use ReflectionType; use Stringable; use function array_column; @@ -556,7 +557,7 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable * @param string $name The name of the entity class the new instance is used for. * @phpstan-param class-string $name */ - public function __construct(public string $name, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null) + public function __construct(public string $name, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null, public readonly bool $inferPhpNullability = false) { $this->rootEntityName = $name; $this->namingStrategy = $namingStrategy ?? new DefaultNamingStrategy(); @@ -1124,14 +1125,12 @@ private function validateAndCompleteTypedFieldMapping(array $mapping): array /** * Validates & completes the basic mapping information based on typed property. * - * @param array{type: self::ONE_TO_ONE|self::MANY_TO_ONE|self::ONE_TO_MANY|self::MANY_TO_MANY, fieldName: string, targetEntity?: class-string} $mapping The mapping. + * @param array{type: self::ONE_TO_ONE|self::MANY_TO_ONE|self::ONE_TO_MANY|self::MANY_TO_MANY, fieldName: string, targetEntity?: class-string, joinColumns: array>|null} $mapping The mapping. * * @return mixed[] The updated mapping. */ - private function validateAndCompleteTypedAssociationMapping(array $mapping): array + private function validateAndCompleteTypedAssociationMapping(array $mapping, ReflectionType|null $type): array { - $type = $this->reflClass->getProperty($mapping['fieldName'])->getType(); - if ($type === null || ($mapping['type'] & self::TO_ONE) === 0) { return $mapping; } @@ -1152,6 +1151,7 @@ private function validateAndCompleteTypedAssociationMapping(array $mapping): arr * id?: bool, * generated?: self::GENERATED_*, * enumType?: class-string, + * nullable?: bool|null, * } $mapping The field mapping to validate & complete. * * @return FieldMapping The updated mapping. @@ -1165,10 +1165,17 @@ protected function validateAndCompleteFieldMapping(array $mapping): FieldMapping throw MappingException::missingFieldName($this->name); } + $type = null; if ($this->isTypedProperty($mapping['fieldName'])) { + $type = $this->reflClass->getProperty($mapping['fieldName'])->getType(); $mapping = $this->validateAndCompleteTypedFieldMapping($mapping); } + // Infer nullable from type or reset null back to true if type is missing + if ($this->inferPhpNullability && ! isset($mapping['nullable'])) { + $mapping['nullable'] = $type?->allowsNull() ?? false; + } + if (! isset($mapping['type'])) { // Default to string $mapping['type'] = 'string'; @@ -1276,8 +1283,29 @@ protected function _validateAndCompleteAssociationMapping(array $mapping): Assoc // the sourceEntity. $mapping['sourceEntity'] = $this->name; + $type = null; if ($this->isTypedProperty($mapping['fieldName'])) { - $mapping = $this->validateAndCompleteTypedAssociationMapping($mapping); + $type = $this->reflClass->getProperty($mapping['fieldName'])->getType(); + $mapping = $this->validateAndCompleteTypedAssociationMapping($mapping, $type); + } + + // Infer nullable from type or reset null back to true if type is missing + if ($this->inferPhpNullability && $mapping['type'] & self::TO_ONE) { + if (! empty($mapping['joinColumns'])) { + foreach ($mapping['joinColumns'] as $key => $data) { + if (! isset($data['nullable'])) { + $mapping['joinColumns'][$key]['nullable'] = $type?->allowsNull() ?? true; + } + } + } elseif ($type !== null) { + $mapping['joinColumns'] = [ + [ + 'fieldName' => $mapping['fieldName'], + 'nullable' => $type->allowsNull(), + 'referencedColumnName' => $this->namingStrategy->referenceColumnName(), + ], + ]; + } } if (isset($mapping['targetEntity'])) { diff --git a/src/Mapping/ClassMetadataFactory.php b/src/Mapping/ClassMetadataFactory.php index b29f20c67b1..978bcd0b28c 100644 --- a/src/Mapping/ClassMetadataFactory.php +++ b/src/Mapping/ClassMetadataFactory.php @@ -304,6 +304,7 @@ protected function newClassMetadataInstance(string $className): ClassMetadata $className, $this->em->getConfiguration()->getNamingStrategy(), $this->em->getConfiguration()->getTypedFieldMapper(), + $this->em->getConfiguration()->isPhpNullabilityInferred(), ); } diff --git a/src/Mapping/Column.php b/src/Mapping/Column.php index e044f5e3144..9da0eb1c9a0 100644 --- a/src/Mapping/Column.php +++ b/src/Mapping/Column.php @@ -10,6 +10,9 @@ #[Attribute(Attribute::TARGET_PROPERTY)] final class Column implements MappingAttribute { + public readonly bool $nullable; + public readonly bool $nullableSet; + /** * @param int|null $precision The precision for a decimal (exact numeric) column (Applies only for decimal column). * @param int|null $scale The scale for a decimal (exact numeric) column (Applies only for decimal column). @@ -24,7 +27,7 @@ public function __construct( public readonly int|null $precision = null, public readonly int|null $scale = null, public readonly bool $unique = false, - public readonly bool $nullable = false, + bool|null $nullable = null, public readonly bool $insertable = true, public readonly bool $updatable = true, public readonly string|null $enumType = null, @@ -32,5 +35,7 @@ public function __construct( public readonly string|null $columnDefinition = null, public readonly string|null $generated = null, ) { + $this->nullable = $nullable ?? false; + $this->nullableSet = $nullable !== null; } } diff --git a/src/Mapping/Driver/AttributeDriver.php b/src/Mapping/Driver/AttributeDriver.php index e337d60ea55..969126ee3e4 100644 --- a/src/Mapping/Driver/AttributeDriver.php +++ b/src/Mapping/Driver/AttributeDriver.php @@ -297,7 +297,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata $joinColumnAttributes = $this->reader->getPropertyAttributeCollection($property, Mapping\JoinColumn::class); foreach ($joinColumnAttributes as $joinColumnAttribute) { - $joinColumns[] = $this->joinColumnToArray($joinColumnAttribute); + $joinColumns[] = $this->joinColumnToArray($joinColumnAttribute, $metadata->inferPhpNullability); } // Field can only be attributed with one of: @@ -310,7 +310,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata $embeddedAttribute = $this->reader->getPropertyAttribute($property, Mapping\Embedded::class); if ($columnAttribute !== null) { - $mapping = $this->columnToArray($property->name, $columnAttribute); + $mapping = $this->columnToArray($property->name, $columnAttribute, $metadata->inferPhpNullability); if ($this->reader->getPropertyAttribute($property, Mapping\Id::class)) { $mapping['id'] = true; @@ -530,7 +530,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata $attributeOverridesAnnot = $classAttributes[Mapping\AttributeOverrides::class]; foreach ($attributeOverridesAnnot->overrides as $attributeOverride) { - $mapping = $this->columnToArray($attributeOverride->name, $attributeOverride->column); + $mapping = $this->columnToArray($attributeOverride->name, $attributeOverride->column, $metadata->inferPhpNullability); $metadata->setAttributeOverride($attributeOverride->name, $mapping); } @@ -680,12 +680,12 @@ private function getMethodCallbacks(ReflectionMethod $method): array * options?: array * } */ - private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn): array + private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn, bool $inferPhpNullability = false): array { $mapping = [ 'name' => $joinColumn->name, 'unique' => $joinColumn->unique, - 'nullable' => $joinColumn->nullable, + 'nullable' => $inferPhpNullability && ! $joinColumn->nullableSet ? null : $joinColumn->nullable, 'onDelete' => $joinColumn->onDelete, 'columnDefinition' => $joinColumn->columnDefinition, 'referencedColumnName' => $joinColumn->referencedColumnName, @@ -708,7 +708,7 @@ private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn * scale: int, * length: int, * unique: bool, - * nullable: bool, + * nullable: bool|null, * precision: int, * enumType?: class-string, * options?: mixed[], @@ -716,7 +716,7 @@ private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn * columnDefinition?: string * } */ - private function columnToArray(string $fieldName, Mapping\Column $column): array + private function columnToArray(string $fieldName, Mapping\Column $column, bool $inferPhpNullability = false): array { $mapping = [ 'fieldName' => $fieldName, @@ -724,7 +724,7 @@ private function columnToArray(string $fieldName, Mapping\Column $column): array 'scale' => $column->scale, 'length' => $column->length, 'unique' => $column->unique, - 'nullable' => $column->nullable, + 'nullable' => $inferPhpNullability && ! $column->nullableSet ? null : $column->nullable, 'precision' => $column->precision, ]; diff --git a/src/Mapping/JoinColumnProperties.php b/src/Mapping/JoinColumnProperties.php index b231d834d79..20403a0ce94 100644 --- a/src/Mapping/JoinColumnProperties.php +++ b/src/Mapping/JoinColumnProperties.php @@ -6,16 +6,21 @@ trait JoinColumnProperties { + public readonly bool $nullable; + public readonly bool $nullableSet; + /** @param array $options */ public function __construct( public readonly string|null $name = null, public readonly string|null $referencedColumnName = null, public readonly bool $unique = false, - public readonly bool $nullable = true, + bool|null $nullable = null, public readonly mixed $onDelete = null, public readonly string|null $columnDefinition = null, public readonly string|null $fieldName = null, public readonly array $options = [], ) { + $this->nullable = $nullable ?? true; + $this->nullableSet = $nullable !== null; } } diff --git a/src/ORMSetup.php b/src/ORMSetup.php index 7354c710fe9..e17680c617d 100644 --- a/src/ORMSetup.php +++ b/src/ORMSetup.php @@ -34,7 +34,7 @@ public static function createAttributeMetadataConfiguration( CacheItemPoolInterface|null $cache = null, ): Configuration { $config = self::createConfiguration($isDevMode, $proxyDir, $cache); - $config->setMetadataDriverImpl(new AttributeDriver($paths)); + $config->setMetadataDriverImpl(new AttributeDriver($paths, true)); return $config; } diff --git a/tests/Tests/Models/TypedProperties/Contact.php b/tests/Tests/Models/TypedProperties/Contact.php index 0229cec95c9..6b33ef357d2 100644 --- a/tests/Tests/Models/TypedProperties/Contact.php +++ b/tests/Tests/Models/TypedProperties/Contact.php @@ -5,10 +5,16 @@ namespace Doctrine\Tests\Models\TypedProperties; use Doctrine\ORM\Mapping as ORM; +use Doctrine\ORM\Mapping\ClassMetadata; #[ORM\Embeddable] class Contact { #[ORM\Column] public string|null $email = null; + + public static function loadMetadata(ClassMetadata $metadata): void + { + $metadata->mapField(['fieldName' => 'email', 'type' => 'string']); + } } diff --git a/tests/Tests/Models/TypedProperties/UserTyped.php b/tests/Tests/Models/TypedProperties/UserTyped.php index 22ac2127ac2..06b28f98ce2 100644 --- a/tests/Tests/Models/TypedProperties/UserTyped.php +++ b/tests/Tests/Models/TypedProperties/UserTyped.php @@ -26,6 +26,12 @@ class UserTyped #[ORM\Column(length: 255, unique: true)] public string $username; + #[ORM\Column(nullable: true)] + public string $firstName; + + #[ORM\Column(nullable: false)] + public string|null $lastName = null; + #[ORM\Column] public DateInterval $dateInterval; @@ -48,8 +54,23 @@ class UserTyped #[ORM\JoinColumn] public CmsEmail $email; + #[ORM\OneToOne] + public CmsEmail|null $emailWithNoJoinColumn; + + #[ORM\OneToOne] + #[ORM\JoinColumn(nullable: false)] + public CmsEmail|null $emailOverride; + + #[ORM\ManyToOne] + #[ORM\JoinColumn] + public CmsEmail $mainEmail; + #[ORM\ManyToOne] - public CmsEmail|null $mainEmail = null; + #[ORM\JoinColumn(nullable: true)] + public CmsEmail $mainEmailOverride; + + #[ORM\ManyToOne] + public CmsEmail|null $mainEmailWithNoJoinColumn = null; #[ORM\Embedded] public Contact|null $contact = null; @@ -75,6 +96,7 @@ public static function loadMetadata(ClassMetadata $metadata): void 'length' => 50, ], ); + $metadata->mapField( [ 'fieldName' => 'username', @@ -82,6 +104,21 @@ public static function loadMetadata(ClassMetadata $metadata): void 'unique' => true, ], ); + + $metadata->mapField( + [ + 'fieldName' => 'firstName', + 'nullable' => true, + ], + ); + + $metadata->mapField( + [ + 'fieldName' => 'lastName', + 'nullable' => false, + ], + ); + $metadata->mapField( ['fieldName' => 'dateInterval'], ); @@ -115,8 +152,51 @@ public static function loadMetadata(ClassMetadata $metadata): void ], ); + $metadata->mapOneToOne( + ['fieldName' => 'emailWithNoJoinColumn'], + ); + + $metadata->mapOneToOne( + [ + 'fieldName' => 'emailOverride', + 'joinColumns' => + [ + 0 => + [ + 'referencedColumnName' => 'id', + 'nullable' => false, + ], + ], + ], + ); + + $metadata->mapManyToOne( + [ + 'fieldName' => 'mainEmail', + 'joinColumns' => + [ + 0 => + ['referencedColumnName' => 'id'], + ], + ], + ); + + $metadata->mapManyToOne( + [ + 'fieldName' => 'mainEmailOverride', + 'joinColumns' => + [ + 0 => + [ + 'referencedColumnName' => 'id', + 'nullable' => true, + ], + ], + ], + ); + $metadata->mapManyToOne( - ['fieldName' => 'mainEmail'], + ['fieldName' => 'mainEmailWithNoJoinColumn'], ); $metadata->mapEmbedded(['fieldName' => 'contact']); diff --git a/tests/Tests/ORM/Mapping/MappingDriverTestCase.php b/tests/Tests/ORM/Mapping/MappingDriverTestCase.php index 33b409f7f57..2ce0cec5853 100644 --- a/tests/Tests/ORM/Mapping/MappingDriverTestCase.php +++ b/tests/Tests/ORM/Mapping/MappingDriverTestCase.php @@ -83,20 +83,21 @@ public function createClassMetadata( string $entityClassName, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null, + bool $inferPhpNullability = false, ): ClassMetadata { $mappingDriver = $this->loadDriver(); - $class = new ClassMetadata($entityClassName, $namingStrategy, $typedFieldMapper); + $class = new ClassMetadata($entityClassName, $namingStrategy, $typedFieldMapper, $inferPhpNullability); $class->initializeReflection(new RuntimeReflectionService()); $mappingDriver->loadMetadataForClass($entityClassName, $class); return $class; } - protected function createClassMetadataFactory(EntityManagerInterface|null $em = null): ClassMetadataFactory + protected function createClassMetadataFactory(EntityManagerInterface|null $em = null, MappingDriver|null $driver = null): ClassMetadataFactory { - $driver = $this->loadDriver(); - $em ??= $this->getTestEntityManager(); + $driver ??= $this->loadDriver(); + $em ??= $this->getTestEntityManager(); $factory = new ClassMetadataFactory(); $em->getConfiguration()->setMetadataDriverImpl($driver); $factory->setEntityManager($em); @@ -957,6 +958,94 @@ public function testCustomNamingStrategyIsRespected(): void self::assertEquals('Id', $metadata->associationMappings['blogPost']->joinColumns[0]->referencedColumnName); self::assertFalse($metadata->associationMappings['blogPost']->joinColumns[0]->nullable); } + + public function testWithInferredNullability(): void + { + $untyped = $this->createClassMetadata(User::class, inferPhpNullability: true); + + // Explicit with missing type + $this->assertTrue($untyped->isNullable('name')); + + // Default with missing type + $this->assertFalse($untyped->isNullable('email')); + + // Default with missing type + $addressMapping = $untyped->getAssociationMapping('address'); + $this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $addressMapping); + $this->assertTrue($addressMapping->joinColumns[0]->nullable); + + $class = $this->createClassMetadata(UserTyped::class, inferPhpNullability: true); + + // Defers to PHP type + $this->assertTrue($class->isNullable('status')); + + // Defers to PHP type + $this->assertFalse($class->isNullable('username')); + + // Override nullable by definition + $this->assertTrue($class->isNullable('firstName')); + $this->assertFalse($class->isNullable('lastName')); + + // Non-nullable by PHP type + foreach (['email', 'mainEmail', 'emailOverride'] as $value) { + $emailMapping = $class->getAssociationMapping($value); + $this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping); + $this->assertFalse($emailMapping->joinColumns[0]->nullable); + } + + // Nullable by PHP type + foreach (['emailWithNoJoinColumn', 'mainEmailWithNoJoinColumn'] as $value) { + $emailMapping = $class->getAssociationMapping($value); + $this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping); + $this->assertTrue($emailMapping->joinColumns[0]->nullable); + } + + // Override nullable by definition (true -> false) + $emailMapping = $class->getAssociationMapping('emailOverride'); + $this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping); + $this->assertFalse($emailMapping->joinColumns[0]->nullable); + + // Override nullable by definition (false -> true) + $emailMapping = $class->getAssociationMapping('mainEmailOverride'); + $this->assertInstanceof(ORM\ManyToOneAssociationMapping::class, $emailMapping); + $this->assertTrue($emailMapping->joinColumns[0]->nullable); + } + + public function testWithoutInferredNullability(): void + { + $class = $this->createClassMetadata(UserTyped::class); + + // Default + $this->assertFalse($class->isNullable('status')); + + // Default + $this->assertFalse($class->isNullable('username')); + + // Explicit + $this->assertTrue($class->isNullable('firstName')); + $this->assertFalse($class->isNullable('lastName')); + + // Nullables by definition + foreach (['email', 'mainEmail', 'mainEmailOverride'] as $value) { + $emailMapping = $class->getAssociationMapping($value); + $this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping); + + // XML and StaticPHP Driver can contain null if joinColumn is not defined + $this->assertNotFalse($emailMapping->joinColumns[0]->nullable); + } + + // JoinColumn not defined + foreach (['emailWithNoJoinColumn', 'mainEmailWithNoJoinColumn'] as $value) { + $emailMapping = $class->getAssociationMapping($value); + $this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping); + $this->assertNull($emailMapping->joinColumns[0]->nullable); + } + + // Not nullable by definition + $emailMapping = $class->getAssociationMapping('emailOverride'); + $this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping); + $this->assertFalse($emailMapping->joinColumns[0]->nullable); + } } #[ORM\Entity()] diff --git a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml new file mode 100644 index 00000000000..49995d1f76b --- /dev/null +++ b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml index a9547af4ab3..0b6d22c4f45 100644 --- a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml +++ b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml @@ -12,6 +12,8 @@ + + @@ -24,7 +26,24 @@ - + + + + + + + + + + + + + + + + + +