Skip to content

Commit 80d2ee7

Browse files
committed
Add support for inherited nullability from PHP
1 parent aff82af commit 80d2ee7

File tree

9 files changed

+182
-18
lines changed

9 files changed

+182
-18
lines changed

docs/en/reference/attributes-reference.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,9 @@ Optional parameters:
176176
should be unique across all rows of the underlying entities table.
177177

178178
- **nullable**: Determines if NULL values allowed for this column.
179-
If not specified, default value is ``false``.
179+
If not specified, default value is ``false``.
180+
Since ORM 3.4, value can be inherited from PHP type when ``ORMSetup::createAttributeMetadataConfiguration()``
181+
or creation of ``AttributeDriver`` has ``$inheritNullabilityFromPropertyType`` set to ``true``.
180182

181183
- **insertable**: Boolean value to determine if the column should be
182184
included when inserting a new row into the underlying entities table.
@@ -674,6 +676,8 @@ Optional parameters:
674676
constraint level. Defaults to false.
675677
- **nullable**: Determine whether the related entity is required, or if
676678
null is an allowed state for the relation. Defaults to true.
679+
Since ORM 3.4, value can be inherited from PHP type when ``ORMSetup::createAttributeMetadataConfiguration()``
680+
or creation of ``AttributeDriver`` has ``$inheritNullabilityFromPropertyType`` set to ``true``.
677681
- **onDelete**: Cascade Action (Database-level)
678682
- **columnDefinition**: DDL SQL snippet that starts after the column
679683
name and specifies the complete (non-portable!) column definition.

src/Mapping/Column.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
#[Attribute(Attribute::TARGET_PROPERTY)]
1111
final class Column implements MappingAttribute
1212
{
13+
private readonly bool $nullableSet;
14+
1315
/**
1416
* @param int|null $precision The precision for a decimal (exact numeric) column (Applies only for decimal column).
1517
* @param int|null $scale The scale for a decimal (exact numeric) column (Applies only for decimal column).
@@ -24,13 +26,25 @@ public function __construct(
2426
public readonly int|null $precision = null,
2527
public readonly int|null $scale = null,
2628
public readonly bool $unique = false,
27-
public readonly bool $nullable = false,
29+
private bool|null $nullable = null,
2830
public readonly bool $insertable = true,
2931
public readonly bool $updatable = true,
3032
public readonly string|null $enumType = null,
3133
public readonly array $options = [],
3234
public readonly string|null $columnDefinition = null,
3335
public readonly string|null $generated = null,
3436
) {
37+
$this->nullableSet = $nullable !== null;
38+
$this->nullable ??= false;
39+
}
40+
41+
public function isNullable(): bool
42+
{
43+
return $this->nullable;
44+
}
45+
46+
public function isNullableSet(): bool
47+
{
48+
return $this->nullableSet;
3549
}
3650
}

src/Mapping/Driver/AttributeDriver.php

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use InvalidArgumentException;
1616
use ReflectionClass;
1717
use ReflectionMethod;
18+
use ReflectionProperty;
1819

1920
use function assert;
2021
use function class_exists;
@@ -38,8 +39,11 @@ class AttributeDriver implements MappingDriver
3839
* @param array<string> $paths
3940
* @param true $reportFieldsWhereDeclared no-op, to be removed in 4.0
4041
*/
41-
public function __construct(array $paths, bool $reportFieldsWhereDeclared = true)
42-
{
42+
public function __construct(
43+
array $paths,
44+
bool $reportFieldsWhereDeclared = true,
45+
private readonly bool $inheritNullabilityFromPropertyType = false,
46+
) {
4347
if (! $reportFieldsWhereDeclared) {
4448
throw new InvalidArgumentException(sprintf(
4549
'The $reportFieldsWhereDeclared argument is no longer supported, make sure to omit it when calling %s.',
@@ -297,7 +301,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
297301
$joinColumnAttributes = $this->reader->getPropertyAttributeCollection($property, Mapping\JoinColumn::class);
298302

299303
foreach ($joinColumnAttributes as $joinColumnAttribute) {
300-
$joinColumns[] = $this->joinColumnToArray($joinColumnAttribute);
304+
$joinColumns[] = $this->joinColumnToArray($joinColumnAttribute, $property);
301305
}
302306

303307
// Field can only be attributed with one of:
@@ -309,8 +313,17 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
309313
$manyToManyAttribute = $this->reader->getPropertyAttribute($property, Mapping\ManyToMany::class);
310314
$embeddedAttribute = $this->reader->getPropertyAttribute($property, Mapping\Embedded::class);
311315

316+
// If the property has a type declaration, and no explicit JoinColumn attributes are set, we can infer nullability from the type declaration
317+
if ($this->inheritNullabilityFromPropertyType && empty($joinColumns) && $property->hasType() && ($oneToOneAttribute !== null || $manyToOneAttribute !== null)) {
318+
$joinColumns = [
319+
[
320+
'nullable' => $property->getType()->allowsNull(),
321+
],
322+
];
323+
}
324+
312325
if ($columnAttribute !== null) {
313-
$mapping = $this->columnToArray($property->name, $columnAttribute);
326+
$mapping = $this->columnToArray($property->name, $columnAttribute, $property);
314327

315328
if ($this->reader->getPropertyAttribute($property, Mapping\Id::class)) {
316329
$mapping['id'] = true;
@@ -680,12 +693,12 @@ private function getMethodCallbacks(ReflectionMethod $method): array
680693
* options?: array<string, mixed>
681694
* }
682695
*/
683-
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn): array
696+
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn, ReflectionProperty|null $property = null): array
684697
{
685698
$mapping = [
686699
'name' => $joinColumn->name,
687700
'unique' => $joinColumn->unique,
688-
'nullable' => $joinColumn->nullable,
701+
'nullable' => $this->detectNullability($property, $joinColumn->isNullable(), $joinColumn->isNullableSet()),
689702
'onDelete' => $joinColumn->onDelete,
690703
'columnDefinition' => $joinColumn->columnDefinition,
691704
'referencedColumnName' => $joinColumn->referencedColumnName,
@@ -716,15 +729,15 @@ private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn
716729
* columnDefinition?: string
717730
* }
718731
*/
719-
private function columnToArray(string $fieldName, Mapping\Column $column): array
732+
private function columnToArray(string $fieldName, Mapping\Column $column, ReflectionProperty|null $property = null): array
720733
{
721734
$mapping = [
722735
'fieldName' => $fieldName,
723736
'type' => $column->type,
724737
'scale' => $column->scale,
725738
'length' => $column->length,
726739
'unique' => $column->unique,
727-
'nullable' => $column->nullable,
740+
'nullable' => $this->detectNullability($property, $column->isNullable(), $column->isNullableSet()),
728741
'precision' => $column->precision,
729742
];
730743

@@ -758,4 +771,18 @@ private function columnToArray(string $fieldName, Mapping\Column $column): array
758771

759772
return $mapping;
760773
}
774+
775+
private function detectNullability(ReflectionProperty|null $property, bool $default, bool $wasSet): bool
776+
{
777+
if (
778+
$this->inheritNullabilityFromPropertyType
779+
&& ! $wasSet
780+
&& $property !== null
781+
&& $property->hasType()
782+
) {
783+
return $property->getType()->allowsNull();
784+
}
785+
786+
return $default;
787+
}
761788
}

src/Mapping/JoinColumnProperties.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,30 @@
66

77
trait JoinColumnProperties
88
{
9+
private readonly bool $nullableSet;
10+
911
/** @param array<string, mixed> $options */
1012
public function __construct(
1113
public readonly string|null $name = null,
1214
public readonly string $referencedColumnName = 'id',
1315
public readonly bool $unique = false,
14-
public readonly bool $nullable = true,
16+
private bool|null $nullable = null,
1517
public readonly mixed $onDelete = null,
1618
public readonly string|null $columnDefinition = null,
1719
public readonly string|null $fieldName = null,
1820
public readonly array $options = [],
1921
) {
22+
$this->nullableSet = $nullable !== null;
23+
$this->nullable ??= true;
24+
}
25+
26+
public function isNullable(): bool
27+
{
28+
return $this->nullable;
29+
}
30+
31+
public function isNullableSet(): bool
32+
{
33+
return $this->nullableSet;
2034
}
2135
}

src/Mapping/ToOneOwningSideMapping.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ public static function fromMappingArrayAndName(
107107
if (empty($joinColumn['name'])) {
108108
$mappingArray['joinColumns'][$index]['name'] = $namingStrategy->joinColumnName($mappingArray['fieldName'], $name);
109109
}
110+
111+
// Added to support JoinColumn from AnnotationDriver that has only nullable information (nullable type detection)
112+
if (empty($joinColumn['referencedColumnName'])) {
113+
$mappingArray['joinColumns'][$index]['referencedColumnName'] = $namingStrategy->referenceColumnName();
114+
}
110115
}
111116
}
112117

src/ORMSetup.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ public static function createAttributeMetadataConfiguration(
3232
bool $isDevMode = false,
3333
string|null $proxyDir = null,
3434
CacheItemPoolInterface|null $cache = null,
35+
bool $inheritNullabilityFromPropertyType = false,
3536
): Configuration {
3637
$config = self::createConfiguration($isDevMode, $proxyDir, $cache);
37-
$config->setMetadataDriverImpl(new AttributeDriver($paths));
38+
$config->setMetadataDriverImpl(new AttributeDriver($paths, true, $inheritNullabilityFromPropertyType));
3839

3940
return $config;
4041
}

tests/Tests/Models/TypedProperties/UserTyped.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ class UserTyped
2626
#[ORM\Column(length: 255, unique: true)]
2727
public string $username;
2828

29+
#[ORM\Column(nullable: true)]
30+
public string $firstName;
31+
32+
#[ORM\Column(nullable: false)]
33+
public string|null $lastName = null;
34+
2935
#[ORM\Column]
3036
public DateInterval $dateInterval;
3137

@@ -48,8 +54,23 @@ class UserTyped
4854
#[ORM\JoinColumn]
4955
public CmsEmail $email;
5056

57+
#[ORM\OneToOne]
58+
public CmsEmail|null $emailWithNoJoinColumn;
59+
60+
#[ORM\OneToOne]
61+
#[ORM\JoinColumn(nullable: false)]
62+
public CmsEmail|null $emailOverride;
63+
64+
#[ORM\ManyToOne]
65+
#[ORM\JoinColumn]
66+
public CmsEmail $mainEmail;
67+
68+
#[ORM\ManyToOne]
69+
#[ORM\JoinColumn(nullable: true)]
70+
public CmsEmail $mainEmailOverride;
71+
5172
#[ORM\ManyToOne]
52-
public CmsEmail|null $mainEmail = null;
73+
public CmsEmail|null $mainEmailWithNoJoinColumn = null;
5374

5475
#[ORM\Embedded]
5576
public Contact|null $contact = null;

tests/Tests/ORM/Mapping/AttributeDriverTest.php

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,18 @@
1111
use Doctrine\ORM\Mapping\JoinColumnMapping;
1212
use Doctrine\ORM\Mapping\MappingAttribute;
1313
use Doctrine\Persistence\Mapping\Driver\MappingDriver;
14+
use Doctrine\Tests\Models\TypedProperties\UserTyped;
1415
use Doctrine\Tests\ORM\Mapping\Fixtures\AttributeEntityWithNestedJoinColumns;
1516
use InvalidArgumentException;
1617
use stdClass;
1718

1819
class AttributeDriverTest extends MappingDriverTestCase
1920
{
20-
protected function loadDriver(): MappingDriver
21+
protected function loadDriver(bool $inheritNullabilityFromPropertyType = false): MappingDriver
2122
{
2223
$paths = [];
2324

24-
return new AttributeDriver($paths, true);
25+
return new AttributeDriver($paths, true, $inheritNullabilityFromPropertyType);
2526
}
2627

2728
public function testOriginallyNestedAttributesDeclaredWithoutOriginalParent(): void
@@ -95,6 +96,83 @@ public function testItThrowsWhenSettingReportFieldsWhereDeclaredToFalse(): void
9596

9697
new AttributeDriver([], false);
9798
}
99+
100+
public function testWithInheritedNullability(): void
101+
{
102+
$factory = $this->createClassMetadataFactory(driver: $this->loadDriver(true));
103+
104+
$class = $factory->getMetadataFor(UserTyped::class);
105+
106+
// Defers to PHP type
107+
$this->assertTrue($class->isNullable('status'));
108+
109+
// Defers to PHP type
110+
$this->assertFalse($class->isNullable('username'));
111+
112+
// Override nullable by definition
113+
$this->assertTrue($class->isNullable('firstName'));
114+
$this->assertFalse($class->isNullable('lastName'));
115+
116+
// Non-nullable by PHP type
117+
foreach (['email', 'mainEmail', 'emailOverride'] as $value) {
118+
$emailMapping = $class->getAssociationMapping($value);
119+
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
120+
$this->assertFalse($emailMapping->joinColumns[0]->nullable);
121+
}
122+
123+
// Nullable by PHP type
124+
foreach (['emailWithNoJoinColumn', 'mainEmailWithNoJoinColumn'] as $value) {
125+
$emailMapping = $class->getAssociationMapping($value);
126+
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
127+
$this->assertTrue($emailMapping->joinColumns[0]->nullable);
128+
}
129+
130+
// Override nullable by definition (true -> false)
131+
$emailMapping = $class->getAssociationMapping('emailOverride');
132+
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
133+
$this->assertFalse($emailMapping->joinColumns[0]->nullable);
134+
135+
// Override nullable by definition (false -> true)
136+
$emailMapping = $class->getAssociationMapping('mainEmailOverride');
137+
$this->assertInstanceof(ORM\ManyToOneAssociationMapping::class, $emailMapping);
138+
$this->assertTrue($emailMapping->joinColumns[0]->nullable);
139+
}
140+
141+
public function testWithoutInheritedNullability(): void
142+
{
143+
$factory = $this->createClassMetadataFactory();
144+
145+
$class = $factory->getMetadataFor(UserTyped::class);
146+
147+
// Default
148+
$this->assertFalse($class->isNullable('status'));
149+
150+
// Default
151+
$this->assertFalse($class->isNullable('username'));
152+
153+
// Explicit
154+
$this->assertTrue($class->isNullable('firstName'));
155+
$this->assertFalse($class->isNullable('lastName'));
156+
157+
// Nullables by definition
158+
foreach (['email', 'mainEmail', 'mainEmailOverride'] as $value) {
159+
$emailMapping = $class->getAssociationMapping($value);
160+
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
161+
$this->assertTrue($emailMapping->joinColumns[0]->nullable);
162+
}
163+
164+
// JoinColumn not defined
165+
foreach (['emailWithNoJoinColumn', 'mainEmailWithNoJoinColumn'] as $value) {
166+
$emailMapping = $class->getAssociationMapping($value);
167+
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
168+
$this->assertNull($emailMapping->joinColumns[0]->nullable);
169+
}
170+
171+
// Not nullable by definition
172+
$emailMapping = $class->getAssociationMapping('emailOverride');
173+
$this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $emailMapping);
174+
$this->assertFalse($emailMapping->joinColumns[0]->nullable);
175+
}
98176
}
99177

100178
#[ORM\Entity]

tests/Tests/ORM/Mapping/MappingDriverTestCase.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,10 @@ public function createClassMetadata(
9292
return $class;
9393
}
9494

95-
protected function createClassMetadataFactory(EntityManagerInterface|null $em = null): ClassMetadataFactory
95+
protected function createClassMetadataFactory(EntityManagerInterface|null $em = null, MappingDriver|null $driver = null): ClassMetadataFactory
9696
{
97-
$driver = $this->loadDriver();
98-
$em ??= $this->getTestEntityManager();
97+
$driver ??= $this->loadDriver();
98+
$em ??= $this->getTestEntityManager();
9999
$factory = new ClassMetadataFactory();
100100
$em->getConfiguration()->setMetadataDriverImpl($driver);
101101
$factory->setEntityManager($em);

0 commit comments

Comments
 (0)