Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Mapping/JoinColumnProperties.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Member

@morozov morozov Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the fact that now this property can contain null a breaking change? I don't really like that a seemingly boolean property is now nullable. Instead, a separate flag could be introduced like $wasNullableSetExplicitly (well, unfortunately, it's a public property that can be modified w/o triggering any code).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's exactly the issue. And with PHP, I don't know of a way to detect that a named argument was explicitly set: https://3v4l.org/uesWA#v8.4.9

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know the impact of this change on the projects, that are all out there when they update ORM. Hard for me to assess.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SenseException let me point out one thing: I did not have to change a single functional test.

public readonly mixed $onDelete = null,
public readonly string|null $columnDefinition = null,
public readonly string|null $fieldName = null,
Expand Down
4 changes: 4 additions & 0 deletions src/Mapping/ManyToManyOwningSideMapping.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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();
}
Expand Down
6 changes: 6 additions & 0 deletions src/Mapping/ToOneOwningSideMapping.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
4 changes: 2 additions & 2 deletions tests/Tests/ORM/Mapping/AttributeDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public function testManyToManyAssociationWithNestedJoinColumns(): void
'name' => 'assoz_id',
'referencedColumnName' => 'assoz_id',
'unique' => false,
'nullable' => true,
'nullable' => null,
'onDelete' => null,
'columnDefinition' => null,
]),
Expand All @@ -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,
]),
Expand Down
10 changes: 5 additions & 5 deletions tests/Tests/ORM/Mapping/ClassMetadataBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ public function testCreateManyToOneWithIdentity(): void
[
'name' => 'group_id',
'referencedColumnName' => 'id',
'nullable' => true,
'nullable' => false,
'unique' => false,
'onDelete' => 'CASCADE',
'columnDefinition' => null,
Expand Down Expand Up @@ -469,7 +469,7 @@ public function testCreateOneToOneWithIdentity(): void
[
'name' => 'group_id',
'referencedColumnName' => 'id',
'nullable' => true,
'nullable' => false,
'unique' => false,
'onDelete' => 'CASCADE',
'columnDefinition' => null,
Expand Down Expand Up @@ -539,7 +539,7 @@ public function testCreateManyToMany(): void
[
'name' => 'group_id',
'referencedColumnName' => 'id',
'nullable' => true,
'nullable' => false,
'unique' => false,
'onDelete' => 'CASCADE',
'columnDefinition' => null,
Expand All @@ -551,7 +551,7 @@ public function testCreateManyToMany(): void
[
'name' => 'user_id',
'referencedColumnName' => 'id',
'nullable' => true,
'nullable' => false,
'unique' => false,
'onDelete' => null,
'columnDefinition' => null,
Expand Down Expand Up @@ -742,7 +742,7 @@ public function testOrphanRemovalOnManyToMany(): void
0 => [
'name' => 'group_id',
'referencedColumnName' => 'id',
'nullable' => true,
'nullable' => false,
'unique' => false,
'onDelete' => 'CASCADE',
'columnDefinition' => null,
Expand Down
55 changes: 55 additions & 0 deletions tests/Tests/ORM/Mapping/ManyToManyOwningSideMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{bool, ManyToManyOwningSideMapping}> */
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),
];
}
}
58 changes: 58 additions & 0 deletions tests/Tests/ORM/Mapping/ManyToOneAssociationMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{bool, ManyToOneAssociationMapping}> */
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),
];
}
}
58 changes: 58 additions & 0 deletions tests/Tests/ORM/Mapping/OneToOneOwningSideMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{bool, OneToOneOwningSideMapping}> */
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),
];
}
}