Skip to content

Commit 8918dca

Browse files
committed
Add support for inherited nullability from PHP
1 parent 4163efd commit 8918dca

16 files changed

+302
-27
lines changed

docs/en/reference/advanced-configuration.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,24 @@ For development you should use an array cache like
184184
``Symfony\Component\Cache\Adapter\ArrayAdapter``
185185
which only caches data on a per-request basis.
186186

187+
Nullability detection (***RECOMMENDED***)
188+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
189+
190+
.. note::
191+
192+
Since ORM 3.4.0
193+
194+
.. code-block:: php
195+
196+
<?php
197+
$config->setInferPhpNullability(true);
198+
199+
Sets whether Doctrine should infer the nullability of PHP types to the
200+
database schema. This is useful when using PHP 7.4+ typed properties
201+
202+
You can always override the inferred nullability by specifying the
203+
``nullable`` option in the Column or JoinColumn definition.
204+
187205
SQL Logger (***Optional***)
188206
~~~~~~~~~~~~~~~~~~~~~~~~~~~
189207

docs/en/reference/attributes-reference.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ 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, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``.
180181

181182
- **insertable**: Boolean value to determine if the column should be
182183
included when inserting a new row into the underlying entities table.
@@ -674,6 +675,7 @@ Optional parameters:
674675
constraint level. Defaults to false.
675676
- **nullable**: Determine whether the related entity is required, or if
676677
null is an allowed state for the relation. Defaults to true.
678+
Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``.
677679
- **onDelete**: Cascade Action (Database-level)
678680
- **columnDefinition**: DDL SQL snippet that starts after the column
679681
name and specifies the complete (non-portable!) column definition.

docs/en/reference/xml-mapping.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ Optional attributes:
256256
- unique - Should this field contain a unique value across the
257257
table? Defaults to false.
258258
- nullable - Should this field allow NULL as a value? Defaults to
259-
false.
259+
false. Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``.
260260
- insertable - Should this field be inserted? Defaults to true.
261261
- updatable - Should this field be updated? Defaults to true.
262262
- generated - Enum of the values ALWAYS, INSERT, NEVER that determines if
@@ -717,6 +717,7 @@ Optional attributes:
717717
This makes sense for Many-To-Many join-columns only to simulate a
718718
one-to-many unidirectional using a join-table.
719719
- nullable - should the join column be nullable, defaults to true.
720+
Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``.
720721
- on-delete - Foreign Key Cascade action to perform when entity is
721722
deleted, defaults to NO ACTION/RESTRICT but can be set to
722723
"CASCADE".

phpstan-baseline.neon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -991,7 +991,7 @@ parameters:
991991
path: src/Mapping/ClassMetadata.php
992992

993993
-
994-
message: '#^Parameter \#1 \$mapping of method Doctrine\\ORM\\Mapping\\ClassMetadata\<T of object\>\:\:validateAndCompleteTypedAssociationMapping\(\) expects array\{type\: 1\|2\|4\|8, fieldName\: string, targetEntity\?\: class\-string\}, non\-empty\-array\<string, mixed\> given\.$#'
994+
message: '#^Parameter \#1 \$mapping of method Doctrine\\ORM\\Mapping\\ClassMetadata\<T of object\>\:\:validateAndCompleteTypedAssociationMapping\(\) expects array\{type\: 1\|2\|4\|8, fieldName\: string, targetEntity\?\: class\-string, joinColumns\: array\<int, array\<string, mixed\>\>\|null\}, non\-empty\-array\<string, mixed\> given\.$#'
995995
identifier: argument.type
996996
count: 1
997997
path: src/Mapping/ClassMetadata.php

src/Configuration.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,4 +644,14 @@ public function getEagerFetchBatchSize(): int
644644
{
645645
return $this->attributes['fetchModeSubselectBatchSize'] ?? 100;
646646
}
647+
648+
public function setInferPhpNullability(bool $inferPhpNullability): void
649+
{
650+
$this->attributes['inferPhpNullability'] = $inferPhpNullability;
651+
}
652+
653+
public function isPhpNullabilityInferred(): bool
654+
{
655+
return $this->attributes['inferPhpNullability'] ?? false;
656+
}
647657
}

src/Mapping/ClassMetadata.php

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use ReflectionClass;
2323
use ReflectionNamedType;
2424
use ReflectionProperty;
25+
use ReflectionType;
2526
use Stringable;
2627

2728
use function array_column;
@@ -556,7 +557,7 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable
556557
* @param string $name The name of the entity class the new instance is used for.
557558
* @phpstan-param class-string<T> $name
558559
*/
559-
public function __construct(public string $name, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null)
560+
public function __construct(public string $name, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null, public readonly bool $inferPhpNullability = false)
560561
{
561562
$this->rootEntityName = $name;
562563
$this->namingStrategy = $namingStrategy ?? new DefaultNamingStrategy();
@@ -1124,14 +1125,12 @@ private function validateAndCompleteTypedFieldMapping(array $mapping): array
11241125
/**
11251126
* Validates & completes the basic mapping information based on typed property.
11261127
*
1127-
* @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.
1128+
* @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<int, array<string, mixed>>|null} $mapping The mapping.
11281129
*
11291130
* @return mixed[] The updated mapping.
11301131
*/
1131-
private function validateAndCompleteTypedAssociationMapping(array $mapping): array
1132+
private function validateAndCompleteTypedAssociationMapping(array $mapping, ReflectionType|null $type): array
11321133
{
1133-
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();
1134-
11351134
if ($type === null || ($mapping['type'] & self::TO_ONE) === 0) {
11361135
return $mapping;
11371136
}
@@ -1152,6 +1151,7 @@ private function validateAndCompleteTypedAssociationMapping(array $mapping): arr
11521151
* id?: bool,
11531152
* generated?: self::GENERATED_*,
11541153
* enumType?: class-string,
1154+
* nullable?: bool|null,
11551155
* } $mapping The field mapping to validate & complete.
11561156
*
11571157
* @return FieldMapping The updated mapping.
@@ -1165,10 +1165,17 @@ protected function validateAndCompleteFieldMapping(array $mapping): FieldMapping
11651165
throw MappingException::missingFieldName($this->name);
11661166
}
11671167

1168+
$type = null;
11681169
if ($this->isTypedProperty($mapping['fieldName'])) {
1170+
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();
11691171
$mapping = $this->validateAndCompleteTypedFieldMapping($mapping);
11701172
}
11711173

1174+
// Infer nullable from type or reset null back to true if type is missing
1175+
if ($this->inferPhpNullability && ! isset($mapping['nullable'])) {
1176+
$mapping['nullable'] = $type?->allowsNull() ?? false;
1177+
}
1178+
11721179
if (! isset($mapping['type'])) {
11731180
// Default to string
11741181
$mapping['type'] = 'string';
@@ -1276,8 +1283,29 @@ protected function _validateAndCompleteAssociationMapping(array $mapping): Assoc
12761283
// the sourceEntity.
12771284
$mapping['sourceEntity'] = $this->name;
12781285

1286+
$type = null;
12791287
if ($this->isTypedProperty($mapping['fieldName'])) {
1280-
$mapping = $this->validateAndCompleteTypedAssociationMapping($mapping);
1288+
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();
1289+
$mapping = $this->validateAndCompleteTypedAssociationMapping($mapping, $type);
1290+
}
1291+
1292+
// Infer nullable from type or reset null back to true if type is missing
1293+
if ($this->inferPhpNullability && $mapping['type'] & self::TO_ONE) {
1294+
if (! empty($mapping['joinColumns'])) {
1295+
foreach ($mapping['joinColumns'] as $key => $data) {
1296+
if (! isset($data['nullable'])) {
1297+
$mapping['joinColumns'][$key]['nullable'] = $type?->allowsNull() ?? true;
1298+
}
1299+
}
1300+
} elseif ($type !== null) {
1301+
$mapping['joinColumns'] = [
1302+
[
1303+
'fieldName' => $mapping['fieldName'],
1304+
'nullable' => $type->allowsNull(),
1305+
'referencedColumnName' => $this->namingStrategy->referenceColumnName(),
1306+
],
1307+
];
1308+
}
12811309
}
12821310

12831311
if (isset($mapping['targetEntity'])) {

src/Mapping/ClassMetadataFactory.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ protected function newClassMetadataInstance(string $className): ClassMetadata
304304
$className,
305305
$this->em->getConfiguration()->getNamingStrategy(),
306306
$this->em->getConfiguration()->getTypedFieldMapper(),
307+
$this->em->getConfiguration()->isPhpNullabilityInferred(),
307308
);
308309
}
309310

src/Mapping/Column.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
#[Attribute(Attribute::TARGET_PROPERTY)]
1111
final class Column implements MappingAttribute
1212
{
13+
public readonly bool $nullable;
14+
public readonly bool $nullableSet;
15+
1316
/**
1417
* @param int|null $precision The precision for a decimal (exact numeric) column (Applies only for decimal column).
1518
* @param int|null $scale The scale for a decimal (exact numeric) column (Applies only for decimal column).
@@ -24,13 +27,15 @@ public function __construct(
2427
public readonly int|null $precision = null,
2528
public readonly int|null $scale = null,
2629
public readonly bool $unique = false,
27-
public readonly bool $nullable = false,
30+
bool|null $nullable = null,
2831
public readonly bool $insertable = true,
2932
public readonly bool $updatable = true,
3033
public readonly string|null $enumType = null,
3134
public readonly array $options = [],
3235
public readonly string|null $columnDefinition = null,
3336
public readonly string|null $generated = null,
3437
) {
38+
$this->nullable = $nullable ?? false;
39+
$this->nullableSet = $nullable !== null;
3540
}
3641
}

src/Mapping/Driver/AttributeDriver.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
297297
$joinColumnAttributes = $this->reader->getPropertyAttributeCollection($property, Mapping\JoinColumn::class);
298298

299299
foreach ($joinColumnAttributes as $joinColumnAttribute) {
300-
$joinColumns[] = $this->joinColumnToArray($joinColumnAttribute);
300+
$joinColumns[] = $this->joinColumnToArray($joinColumnAttribute, $metadata->inferPhpNullability);
301301
}
302302

303303
// Field can only be attributed with one of:
@@ -310,7 +310,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
310310
$embeddedAttribute = $this->reader->getPropertyAttribute($property, Mapping\Embedded::class);
311311

312312
if ($columnAttribute !== null) {
313-
$mapping = $this->columnToArray($property->name, $columnAttribute);
313+
$mapping = $this->columnToArray($property->name, $columnAttribute, $metadata->inferPhpNullability);
314314

315315
if ($this->reader->getPropertyAttribute($property, Mapping\Id::class)) {
316316
$mapping['id'] = true;
@@ -530,7 +530,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
530530
$attributeOverridesAnnot = $classAttributes[Mapping\AttributeOverrides::class];
531531

532532
foreach ($attributeOverridesAnnot->overrides as $attributeOverride) {
533-
$mapping = $this->columnToArray($attributeOverride->name, $attributeOverride->column);
533+
$mapping = $this->columnToArray($attributeOverride->name, $attributeOverride->column, $metadata->inferPhpNullability);
534534

535535
$metadata->setAttributeOverride($attributeOverride->name, $mapping);
536536
}
@@ -680,12 +680,12 @@ private function getMethodCallbacks(ReflectionMethod $method): array
680680
* options?: array<string, mixed>
681681
* }
682682
*/
683-
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn): array
683+
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn, bool $inferPhpNullability = false): array
684684
{
685685
$mapping = [
686686
'name' => $joinColumn->name,
687687
'unique' => $joinColumn->unique,
688-
'nullable' => $joinColumn->nullable,
688+
'nullable' => $inferPhpNullability && ! $joinColumn->nullableSet ? null : $joinColumn->nullable,
689689
'onDelete' => $joinColumn->onDelete,
690690
'columnDefinition' => $joinColumn->columnDefinition,
691691
'referencedColumnName' => $joinColumn->referencedColumnName,
@@ -708,23 +708,23 @@ private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn
708708
* scale: int,
709709
* length: int,
710710
* unique: bool,
711-
* nullable: bool,
711+
* nullable: bool|null,
712712
* precision: int,
713713
* enumType?: class-string,
714714
* options?: mixed[],
715715
* columnName?: string,
716716
* columnDefinition?: string
717717
* }
718718
*/
719-
private function columnToArray(string $fieldName, Mapping\Column $column): array
719+
private function columnToArray(string $fieldName, Mapping\Column $column, bool $inferPhpNullability = false): array
720720
{
721721
$mapping = [
722722
'fieldName' => $fieldName,
723723
'type' => $column->type,
724724
'scale' => $column->scale,
725725
'length' => $column->length,
726726
'unique' => $column->unique,
727-
'nullable' => $column->nullable,
727+
'nullable' => $inferPhpNullability && ! $column->nullableSet ? null : $column->nullable,
728728
'precision' => $column->precision,
729729
];
730730

src/Mapping/JoinColumnProperties.php

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

77
trait JoinColumnProperties
88
{
9+
public readonly bool $nullable;
10+
public readonly bool $nullableSet;
11+
912
/** @param array<string, mixed> $options */
1013
public function __construct(
1114
public readonly string|null $name = null,
1215
public readonly string|null $referencedColumnName = null,
1316
public readonly bool $unique = false,
14-
public readonly bool $nullable = true,
17+
bool|null $nullable = null,
1518
public readonly mixed $onDelete = null,
1619
public readonly string|null $columnDefinition = null,
1720
public readonly string|null $fieldName = null,
1821
public readonly array $options = [],
1922
) {
23+
$this->nullable = $nullable ?? true;
24+
$this->nullableSet = $nullable !== null;
2025
}
2126
}

0 commit comments

Comments
 (0)