diff --git a/src/Mapping/JoinColumnProperties.php b/src/Mapping/JoinColumnProperties.php index dcf23439b5..e7e68989f0 100644 --- a/src/Mapping/JoinColumnProperties.php +++ b/src/Mapping/JoinColumnProperties.php @@ -12,7 +12,7 @@ public function __construct( public readonly string|null $referencedColumnName = null, public readonly bool $deferrable = false, public readonly bool $unique = false, - public readonly bool $nullable = true, + public readonly bool|null $nullable = null, public readonly mixed $onDelete = null, public readonly string|null $columnDefinition = null, public readonly string|null $fieldName = null, diff --git a/src/Mapping/ManyToManyOwningSideMapping.php b/src/Mapping/ManyToManyOwningSideMapping.php index b4ca899a8d..117146f8ff 100644 --- a/src/Mapping/ManyToManyOwningSideMapping.php +++ b/src/Mapping/ManyToManyOwningSideMapping.php @@ -127,6 +127,8 @@ public static function fromMappingArrayAndNamingStrategy(array $mappingArray, Na $mapping->joinTableColumns = []; foreach ($mapping->joinTable->joinColumns as $joinColumn) { + $joinColumn->nullable = false; + if (empty($joinColumn->referencedColumnName)) { $joinColumn->referencedColumnName = $namingStrategy->referenceColumnName(); } @@ -150,6 +152,8 @@ public static function fromMappingArrayAndNamingStrategy(array $mappingArray, Na } foreach ($mapping->joinTable->inverseJoinColumns as $inverseJoinColumn) { + $inverseJoinColumn->nullable = false; + if (empty($inverseJoinColumn->referencedColumnName)) { $inverseJoinColumn->referencedColumnName = $namingStrategy->referenceColumnName(); } diff --git a/src/Mapping/ToOneOwningSideMapping.php b/src/Mapping/ToOneOwningSideMapping.php index 718679f688..8ed43aa6dd 100644 --- a/src/Mapping/ToOneOwningSideMapping.php +++ b/src/Mapping/ToOneOwningSideMapping.php @@ -130,6 +130,12 @@ public static function fromMappingArrayAndName( $uniqueConstraintColumns = []; foreach ($mapping->joinColumns as $joinColumn) { + if ($mapping->id) { + $joinColumn->nullable = false; + } elseif ($joinColumn->nullable === null) { + $joinColumn->nullable = true; + } + if ($mapping->isOneToOne() && ! $isInheritanceTypeSingleTable) { if (count($mapping->joinColumns) === 1) { if (empty($mapping->id)) { diff --git a/tests/Tests/ORM/Mapping/AttributeDriverTest.php b/tests/Tests/ORM/Mapping/AttributeDriverTest.php index b16ec788d3..58e9de38f6 100644 --- a/tests/Tests/ORM/Mapping/AttributeDriverTest.php +++ b/tests/Tests/ORM/Mapping/AttributeDriverTest.php @@ -66,7 +66,7 @@ public function testManyToManyAssociationWithNestedJoinColumns(): void 'name' => 'assoz_id', 'referencedColumnName' => 'assoz_id', 'unique' => false, - 'nullable' => true, + 'nullable' => null, 'onDelete' => null, 'columnDefinition' => null, ]), @@ -80,7 +80,7 @@ public function testManyToManyAssociationWithNestedJoinColumns(): void 'name' => 'inverse_assoz_id', 'referencedColumnName' => 'inverse_assoz_id', 'unique' => false, - 'nullable' => true, + 'nullable' => null, 'onDelete' => null, 'columnDefinition' => null, ]), diff --git a/tests/Tests/ORM/Mapping/ClassMetadataBuilderTest.php b/tests/Tests/ORM/Mapping/ClassMetadataBuilderTest.php index 58ea2202ef..ddabbda3a7 100644 --- a/tests/Tests/ORM/Mapping/ClassMetadataBuilderTest.php +++ b/tests/Tests/ORM/Mapping/ClassMetadataBuilderTest.php @@ -364,7 +364,7 @@ public function testCreateManyToOneWithIdentity(): void [ 'name' => 'group_id', 'referencedColumnName' => 'id', - 'nullable' => true, + 'nullable' => false, 'unique' => false, 'onDelete' => 'CASCADE', 'columnDefinition' => null, @@ -469,7 +469,7 @@ public function testCreateOneToOneWithIdentity(): void [ 'name' => 'group_id', 'referencedColumnName' => 'id', - 'nullable' => true, + 'nullable' => false, 'unique' => false, 'onDelete' => 'CASCADE', 'columnDefinition' => null, @@ -539,7 +539,7 @@ public function testCreateManyToMany(): void [ 'name' => 'group_id', 'referencedColumnName' => 'id', - 'nullable' => true, + 'nullable' => false, 'unique' => false, 'onDelete' => 'CASCADE', 'columnDefinition' => null, @@ -551,7 +551,7 @@ public function testCreateManyToMany(): void [ 'name' => 'user_id', 'referencedColumnName' => 'id', - 'nullable' => true, + 'nullable' => false, 'unique' => false, 'onDelete' => null, 'columnDefinition' => null, @@ -742,7 +742,7 @@ public function testOrphanRemovalOnManyToMany(): void 0 => [ 'name' => 'group_id', 'referencedColumnName' => 'id', - 'nullable' => true, + 'nullable' => false, 'unique' => false, 'onDelete' => 'CASCADE', 'columnDefinition' => null, diff --git a/tests/Tests/ORM/Mapping/ManyToManyOwningSideMappingTest.php b/tests/Tests/ORM/Mapping/ManyToManyOwningSideMappingTest.php index cc2e2e74ca..7c8956ef3e 100644 --- a/tests/Tests/ORM/Mapping/ManyToManyOwningSideMappingTest.php +++ b/tests/Tests/ORM/Mapping/ManyToManyOwningSideMappingTest.php @@ -4,8 +4,10 @@ namespace Doctrine\Tests\ORM\Mapping; +use Doctrine\ORM\Mapping\DefaultNamingStrategy; use Doctrine\ORM\Mapping\JoinTableMapping; use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use function assert; @@ -35,4 +37,57 @@ public function testItSurvivesSerialization(): void self::assertSame(['foo' => 'bar'], $resurrectedMapping->relationToSourceKeyColumns); self::assertSame(['bar' => 'baz'], $resurrectedMapping->relationToTargetKeyColumns); } + + #[DataProvider('mappingsProvider')] + public function testNullableDefaults(bool $expectedValue, ManyToManyOwningSideMapping $mapping): void + { + foreach ($mapping->joinTable->joinColumns as $joinColumn) { + self::assertSame($expectedValue, $joinColumn->nullable); + } + } + + /** @return iterable */ + public static function mappingsProvider(): iterable + { + $namingStrategy = new DefaultNamingStrategy(); + + yield 'defaults to false' => [ + false, + ManyToManyOwningSideMapping::fromMappingArrayAndNamingStrategy([ + 'fieldName' => 'foo', + 'sourceEntity' => self::class, + 'targetEntity' => self::class, + 'isOwningSide' => true, + 'joinTable' => [ + 'name' => 'bar', + 'joinColumns' => [ + ['name' => 'bar_id', 'referencedColumnName' => 'id'], + ], + 'inverseJoinColumns' => [ + ['name' => 'foo_id', 'referencedColumnName' => 'id'], + ], + ], + ], $namingStrategy), + ]; + + yield 'explicitly marked as nullable' => [ + false, // user's intent is ignored at the ORM level + ManyToManyOwningSideMapping::fromMappingArrayAndNamingStrategy([ + 'fieldName' => 'foo', + 'sourceEntity' => self::class, + 'targetEntity' => self::class, + 'isOwningSide' => true, + 'joinTable' => [ + 'name' => 'bar', + 'joinColumns' => [ + ['name' => 'bar_id', 'referencedColumnName' => 'id', 'nullable' => true], + ], + 'inverseJoinColumns' => [ + ['name' => 'foo_id', 'referencedColumnName' => 'id', 'nullable' => true], + ], + ], + 'id' => true, + ], $namingStrategy), + ]; + } } diff --git a/tests/Tests/ORM/Mapping/ManyToOneAssociationMappingTest.php b/tests/Tests/ORM/Mapping/ManyToOneAssociationMappingTest.php index 3833e51ef5..1d57d2f0be 100644 --- a/tests/Tests/ORM/Mapping/ManyToOneAssociationMappingTest.php +++ b/tests/Tests/ORM/Mapping/ManyToOneAssociationMappingTest.php @@ -4,8 +4,10 @@ namespace Doctrine\Tests\ORM\Mapping; +use Doctrine\ORM\Mapping\DefaultNamingStrategy; use Doctrine\ORM\Mapping\JoinColumnMapping; use Doctrine\ORM\Mapping\ManyToOneAssociationMapping; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use function assert; @@ -35,4 +37,60 @@ public function testItSurvivesSerialization(): void self::assertSame(['foo' => 'bar'], $resurrectedMapping->sourceToTargetKeyColumns); self::assertSame(['bar' => 'foo'], $resurrectedMapping->targetToSourceKeyColumns); } + + #[DataProvider('mappingsProvider')] + public function testNullableDefaults(bool $expectedValue, ManyToOneAssociationMapping $mapping): void + { + foreach ($mapping->joinColumns as $joinColumn) { + self::assertSame($expectedValue, $joinColumn->nullable); + } + } + + /** @return iterable */ + public static function mappingsProvider(): iterable + { + $namingStrategy = new DefaultNamingStrategy(); + + yield 'not part of the identifier' => [ + true, + ManyToOneAssociationMapping::fromMappingArrayAndName([ + 'fieldName' => 'foo', + 'sourceEntity' => self::class, + 'targetEntity' => self::class, + 'isOwningSide' => true, + 'joinColumns' => [ + ['name' => 'foo_id', 'referencedColumnName' => 'id'], + ], + 'id' => false, + ], $namingStrategy, self::class, null, false), + ]; + + yield 'part of the identifier' => [ + false, + ManyToOneAssociationMapping::fromMappingArrayAndName([ + 'fieldName' => 'foo', + 'sourceEntity' => self::class, + 'targetEntity' => self::class, + 'isOwningSide' => true, + 'joinColumns' => [ + ['name' => 'foo_id', 'referencedColumnName' => 'id'], + ], + 'id' => true, + ], $namingStrategy, self::class, null, false), + ]; + + yield 'part of the identifier, but explicitly marked as nullable' => [ + false, // user's intent is ignored at the ORM level + ManyToOneAssociationMapping::fromMappingArrayAndName([ + 'fieldName' => 'foo', + 'sourceEntity' => self::class, + 'targetEntity' => self::class, + 'isOwningSide' => true, + 'joinColumns' => [ + ['name' => 'foo_id', 'referencedColumnName' => 'id', 'nullable' => true], + ], + 'id' => true, + ], $namingStrategy, self::class, null, false), + ]; + } } diff --git a/tests/Tests/ORM/Mapping/OneToOneOwningSideMappingTest.php b/tests/Tests/ORM/Mapping/OneToOneOwningSideMappingTest.php index 73875653da..8294a6075a 100644 --- a/tests/Tests/ORM/Mapping/OneToOneOwningSideMappingTest.php +++ b/tests/Tests/ORM/Mapping/OneToOneOwningSideMappingTest.php @@ -4,8 +4,10 @@ namespace Doctrine\Tests\ORM\Mapping; +use Doctrine\ORM\Mapping\DefaultNamingStrategy; use Doctrine\ORM\Mapping\JoinColumnMapping; use Doctrine\ORM\Mapping\OneToOneOwningSideMapping; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use function assert; @@ -35,4 +37,60 @@ public function testItSurvivesSerialization(): void self::assertSame(['foo' => 'bar'], $resurrectedMapping->sourceToTargetKeyColumns); self::assertSame(['bar' => 'foo'], $resurrectedMapping->targetToSourceKeyColumns); } + + #[DataProvider('mappingsProvider')] + public function testNullableDefaults(bool $expectedValue, OneToOneOwningSideMapping $mapping): void + { + foreach ($mapping->joinColumns as $joinColumn) { + self::assertSame($expectedValue, $joinColumn->nullable); + } + } + + /** @return iterable */ + public static function mappingsProvider(): iterable + { + $namingStrategy = new DefaultNamingStrategy(); + + yield 'not part of the identifier' => [ + true, + OneToOneOwningSideMapping::fromMappingArrayAndName([ + 'fieldName' => 'foo', + 'sourceEntity' => self::class, + 'targetEntity' => self::class, + 'isOwningSide' => true, + 'joinColumns' => [ + ['name' => 'foo_id', 'referencedColumnName' => 'id'], + ], + 'id' => false, + ], $namingStrategy, self::class, null, false), + ]; + + yield 'part of the identifier' => [ + false, + OneToOneOwningSideMapping::fromMappingArrayAndName([ + 'fieldName' => 'foo', + 'sourceEntity' => self::class, + 'targetEntity' => self::class, + 'isOwningSide' => true, + 'joinColumns' => [ + ['name' => 'foo_id', 'referencedColumnName' => 'id'], + ], + 'id' => true, + ], $namingStrategy, self::class, null, false), + ]; + + yield 'part of the identifier, but explicitly marked as nullable' => [ + false, // user's intent ignored at the ORM level + OneToOneOwningSideMapping::fromMappingArrayAndName([ + 'fieldName' => 'foo', + 'sourceEntity' => self::class, + 'targetEntity' => self::class, + 'isOwningSide' => true, + 'joinColumns' => [ + ['name' => 'foo_id', 'referencedColumnName' => 'id', 'nullable' => true], + ], + 'id' => true, + ], $namingStrategy, self::class, null, false), + ]; + } }