Skip to content

Commit 9baf90c

Browse files
committed
Add support for inherited nullability from PHP
1 parent 4664373 commit 9baf90c

16 files changed

+296
-37
lines changed

docs/en/reference/advanced-configuration.rst

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,24 @@ For development you should use an array cache like
209209
``Symfony\Component\Cache\Adapter\ArrayAdapter``
210210
which only caches data on a per-request basis.
211211

212-
SQL Logger (**Optional**)
212+
Nullability detection (**RECOMMENDED**)
213+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
214+
215+
.. note::
216+
217+
Since ORM 3.5.0
218+
219+
.. code-block:: php
220+
221+
<?php
222+
$config->setInferNullabilityFromPHPType(true);
223+
224+
Sets whether Doctrine should infer columns nullability from PHP types declarations.
225+
226+
You can always override the inferred nullability by specifying the
227+
``nullable`` option in the Column or JoinColumn definition.
228+
229+
SQL Logger (**OPTIONAL**)
213230
~~~~~~~~~~~~~~~~~~~~~~~~~
214231

215232
.. code-block:: php

docs/en/reference/association-mapping.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -901,9 +901,11 @@ join columns default to the simple, unqualified class name of the
901901
targeted class followed by "\_id". The referencedColumnName always
902902
defaults to "id", just as in one-to-one or many-to-one mappings.
903903

904-
Additionally, when using typed properties with Doctrine 2.9 or newer
904+
Additionally, when using typed properties with ORM 2.9 or newer
905905
you can skip ``targetEntity`` in ``ManyToOne`` and ``OneToOne``
906-
associations as they will be set based on type. So that:
906+
associations as they will be set based on type. Also with ORM 3.5
907+
or newer, the ``nullable`` attribute on ``JoinColumn`` will be inferred
908+
from PHP type. So that:
907909

908910
.. configuration-block::
909911

docs/en/reference/attributes-reference.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ Optional parameters:
177177

178178
- **nullable**: Determines if NULL values allowed for this column.
179179
If not specified, default value is ``false``.
180+
Since ORM 3.5, default can be inferred from PHP type when using ``$config->setInferNullabilityFromPHPType(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.
@@ -679,6 +680,7 @@ Optional parameters:
679680
- **deferrable**: Determines whether this relation constraint can be deferred. Defaults to false.
680681
- **nullable**: Determine whether the related entity is required, or if
681682
null is an allowed state for the relation. Defaults to true.
683+
Since ORM 3.5, default can be inferred from PHP type when using ``$config->setInferNullabilityFromPHPType(true)``.
682684
- **onDelete**: Cascade Action (Database-level)
683685
- **columnDefinition**: DDL SQL snippet that starts after the column
684686
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.5, default can be inferred from PHP type when using ``$config->setInferNullabilityFromPHPType(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.5, default can be inferred from PHP type when using ``$config->setInferNullabilityFromPHPType(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
@@ -985,7 +985,7 @@ parameters:
985985
path: src/Mapping/ClassMetadata.php
986986

987987
-
988-
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\.$#'
988+
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\.$#'
989989
identifier: argument.type
990990
count: 1
991991
path: src/Mapping/ClassMetadata.php

src/Configuration.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,4 +660,14 @@ public function getEagerFetchBatchSize(): int
660660
{
661661
return $this->attributes['fetchModeSubselectBatchSize'] ?? 100;
662662
}
663+
664+
public function setInferNullabilityFromPHPType(bool $inferNullabilityFromPHPType): void
665+
{
666+
$this->attributes['inferNullabilityFromPHPType'] = $inferNullabilityFromPHPType;
667+
}
668+
669+
public function isNullabilityInferredFromPHPType(): bool
670+
{
671+
return $this->attributes['inferNullabilityFromPHPType'] ?? false;
672+
}
663673
}

src/Mapping/ClassMetadata.php

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use ReflectionClass;
2626
use ReflectionNamedType;
2727
use ReflectionProperty;
28+
use ReflectionType;
2829
use Stringable;
2930

3031
use function array_column;
@@ -562,8 +563,12 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable
562563
* @param string $name The name of the entity class the new instance is used for.
563564
* @phpstan-param class-string<T> $name
564565
*/
565-
public function __construct(public string $name, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null)
566-
{
566+
public function __construct(
567+
public string $name,
568+
NamingStrategy|null $namingStrategy = null,
569+
TypedFieldMapper|null $typedFieldMapper = null,
570+
public readonly bool $inferNullabilityFromPHPType = false,
571+
) {
567572
$this->rootEntityName = $name;
568573
$this->namingStrategy = $namingStrategy ?? new DefaultNamingStrategy();
569574
$this->instantiator = new Instantiator();
@@ -1149,14 +1154,17 @@ private function validateAndCompleteTypedFieldMapping(array $mapping): array
11491154
/**
11501155
* Validates & completes the basic mapping information based on typed property.
11511156
*
1152-
* @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.
1157+
* @phpstan-param array{
1158+
* type: self::ONE_TO_ONE|self::MANY_TO_ONE|self::ONE_TO_MANY|self::MANY_TO_MANY,
1159+
* fieldName: string,
1160+
* targetEntity?: class-string,
1161+
* joinColumns: array<int, array<string, mixed>>|null,
1162+
* } $mapping The mapping.
11531163
*
11541164
* @return mixed[] The updated mapping.
11551165
*/
1156-
private function validateAndCompleteTypedAssociationMapping(array $mapping): array
1166+
private function validateAndCompleteTypedAssociationMapping(array $mapping, ReflectionType|null $type): array
11571167
{
1158-
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();
1159-
11601168
if ($type === null || ($mapping['type'] & self::TO_ONE) === 0) {
11611169
return $mapping;
11621170
}
@@ -1177,6 +1185,7 @@ private function validateAndCompleteTypedAssociationMapping(array $mapping): arr
11771185
* id?: bool,
11781186
* generated?: self::GENERATED_*,
11791187
* enumType?: class-string,
1188+
* nullable?: bool|null,
11801189
* } $mapping The field mapping to validate & complete.
11811190
*
11821191
* @return FieldMapping The updated mapping.
@@ -1190,10 +1199,17 @@ protected function validateAndCompleteFieldMapping(array $mapping): FieldMapping
11901199
throw MappingException::missingFieldName($this->name);
11911200
}
11921201

1202+
$type = null;
11931203
if ($this->isTypedProperty($mapping['fieldName'])) {
1204+
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();
11941205
$mapping = $this->validateAndCompleteTypedFieldMapping($mapping);
11951206
}
11961207

1208+
// Infer nullable from type or reset null back to false if type is missing
1209+
if ($this->inferNullabilityFromPHPType && ! isset($mapping['nullable'])) {
1210+
$mapping['nullable'] = $type?->allowsNull() ?? false;
1211+
}
1212+
11971213
if (! isset($mapping['type'])) {
11981214
// Default to string
11991215
$mapping['type'] = 'string';
@@ -1301,8 +1317,10 @@ protected function _validateAndCompleteAssociationMapping(array $mapping): Assoc
13011317
// the sourceEntity.
13021318
$mapping['sourceEntity'] = $this->name;
13031319

1320+
$type = null;
13041321
if ($this->isTypedProperty($mapping['fieldName'])) {
1305-
$mapping = $this->validateAndCompleteTypedAssociationMapping($mapping);
1322+
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();
1323+
$mapping = $this->validateAndCompleteTypedAssociationMapping($mapping, $type);
13061324
}
13071325

13081326
if (isset($mapping['targetEntity'])) {
@@ -1369,6 +1387,25 @@ protected function _validateAndCompleteAssociationMapping(array $mapping): Assoc
13691387
$mapping['isOwningSide'] = false;
13701388
}
13711389

1390+
// Infer nullable from type or reset null back to true if type is missing
1391+
if ($this->inferNullabilityFromPHPType && $mapping['type'] & self::TO_ONE) {
1392+
if (! empty($mapping['joinColumns'])) {
1393+
foreach ($mapping['joinColumns'] as $key => $data) {
1394+
if (! isset($data['nullable'])) {
1395+
$mapping['joinColumns'][$key]['nullable'] = $type?->allowsNull() ?? true;
1396+
}
1397+
}
1398+
} elseif ($type !== null && ($mapping['type'] !== self::ONE_TO_ONE || $mapping['isOwningSide'])) { // Ignoring inverse side
1399+
$mapping['joinColumns'] = [
1400+
[
1401+
'fieldName' => $mapping['fieldName'],
1402+
'nullable' => $type->allowsNull(),
1403+
'referencedColumnName' => $this->namingStrategy->referenceColumnName(),
1404+
],
1405+
];
1406+
}
1407+
}
1408+
13721409
if (isset($mapping['id']) && $mapping['id'] === true && $mapping['type'] & self::TO_MANY) {
13731410
throw MappingException::illegalToManyIdentifierAssociation($this->name, $mapping['fieldName']);
13741411
}

src/Mapping/ClassMetadataFactory.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ protected function newClassMetadataInstance(string $className): ClassMetadata
306306
$className,
307307
$this->em->getConfiguration()->getNamingStrategy(),
308308
$this->em->getConfiguration()->getTypedFieldMapper(),
309+
$this->em->getConfiguration()->isNullabilityInferredFromPHPType(),
309310
);
310311
}
311312

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: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -291,15 +291,6 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
291291
);
292292
}
293293

294-
// Check for JoinColumn/JoinColumns attributes
295-
$joinColumns = [];
296-
297-
$joinColumnAttributes = $this->reader->getPropertyAttributeCollection($property, Mapping\JoinColumn::class);
298-
299-
foreach ($joinColumnAttributes as $joinColumnAttribute) {
300-
$joinColumns[] = $this->joinColumnToArray($joinColumnAttribute);
301-
}
302-
303294
// Field can only be attributed with one of:
304295
// Column, OneToOne, OneToMany, ManyToOne, ManyToMany, Embedded
305296
$columnAttribute = $this->reader->getPropertyAttribute($property, Mapping\Column::class);
@@ -309,8 +300,18 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
309300
$manyToManyAttribute = $this->reader->getPropertyAttribute($property, Mapping\ManyToMany::class);
310301
$embeddedAttribute = $this->reader->getPropertyAttribute($property, Mapping\Embedded::class);
311302

303+
// Check for JoinColumn/JoinColumns attributes
304+
$joinColumns = [];
305+
306+
$joinColumnAttributes = $this->reader->getPropertyAttributeCollection($property, Mapping\JoinColumn::class);
307+
308+
foreach ($joinColumnAttributes as $joinColumnAttribute) {
309+
$joinColumns[] = $this->joinColumnToArray($joinColumnAttribute, $metadata->inferNullabilityFromPHPType && (
310+
$oneToOneAttribute !== null || $manyToOneAttribute !== null));
311+
}
312+
312313
if ($columnAttribute !== null) {
313-
$mapping = $this->columnToArray($property->name, $columnAttribute);
314+
$mapping = $this->columnToArray($property->name, $columnAttribute, $metadata->inferNullabilityFromPHPType);
314315

315316
if ($this->reader->getPropertyAttribute($property, Mapping\Id::class)) {
316317
$mapping['id'] = true;
@@ -473,10 +474,12 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
473474

474475
// Check for JoinColumn/JoinColumns attributes
475476
if ($associationOverride->joinColumns) {
476-
$joinColumns = [];
477+
$inferNullabilityFromPHPType = $metadata->inferNullabilityFromPHPType && isset($metadata->associationMappings[$fieldName])
478+
&& $metadata->associationMappings[$fieldName]['type'] & ClassMetadata::TO_ONE;
477479

480+
$joinColumns = [];
478481
foreach ($associationOverride->joinColumns as $joinColumn) {
479-
$joinColumns[] = $this->joinColumnToArray($joinColumn);
482+
$joinColumns[] = $this->joinColumnToArray($joinColumn, $inferNullabilityFromPHPType);
480483
}
481484

482485
$override['joinColumns'] = $joinColumns;
@@ -530,7 +533,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
530533
$attributeOverridesAnnot = $classAttributes[Mapping\AttributeOverrides::class];
531534

532535
foreach ($attributeOverridesAnnot->overrides as $attributeOverride) {
533-
$mapping = $this->columnToArray($attributeOverride->name, $attributeOverride->column);
536+
$mapping = $this->columnToArray($attributeOverride->name, $attributeOverride->column, $metadata->inferNullabilityFromPHPType);
534537

535538
$metadata->setAttributeOverride($attributeOverride->name, $mapping);
536539
}
@@ -673,25 +676,28 @@ private function getMethodCallbacks(ReflectionMethod $method): array
673676
* @phpstan-return array{
674677
* name: string|null,
675678
* unique: bool,
676-
* nullable: bool,
679+
* nullable?: bool,
677680
* onDelete: mixed,
678681
* columnDefinition: string|null,
679682
* referencedColumnName: string,
680683
* options?: array<string, mixed>
681684
* }
682685
*/
683-
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn): array
686+
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn, bool $inferNullabilityFromPHPType = false): array
684687
{
685688
$mapping = [
686689
'name' => $joinColumn->name,
687690
'deferrable' => $joinColumn->deferrable,
688691
'unique' => $joinColumn->unique,
689-
'nullable' => $joinColumn->nullable,
690692
'onDelete' => $joinColumn->onDelete,
691693
'columnDefinition' => $joinColumn->columnDefinition,
692694
'referencedColumnName' => $joinColumn->referencedColumnName,
693695
];
694696

697+
if (! $inferNullabilityFromPHPType || $joinColumn->nullableSet) {
698+
$mapping['nullable'] = $joinColumn->nullable;
699+
}
700+
695701
if ($joinColumn->options) {
696702
$mapping['options'] = $joinColumn->options;
697703
}
@@ -709,26 +715,29 @@ private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn
709715
* scale: int,
710716
* length: int,
711717
* unique: bool,
712-
* nullable: bool,
718+
* nullable?: bool|null,
713719
* precision: int,
714720
* enumType?: class-string,
715721
* options?: mixed[],
716722
* columnName?: string,
717723
* columnDefinition?: string
718724
* }
719725
*/
720-
private function columnToArray(string $fieldName, Mapping\Column $column): array
726+
private function columnToArray(string $fieldName, Mapping\Column $column, bool $inferNullabilityFromPHPType = false): array
721727
{
722728
$mapping = [
723729
'fieldName' => $fieldName,
724730
'type' => $column->type,
725731
'scale' => $column->scale,
726732
'length' => $column->length,
727733
'unique' => $column->unique,
728-
'nullable' => $column->nullable,
729734
'precision' => $column->precision,
730735
];
731736

737+
if (! $inferNullabilityFromPHPType || $column->nullableSet) {
738+
$mapping['nullable'] = $column->nullable;
739+
}
740+
732741
if ($column->options) {
733742
$mapping['options'] = $column->options;
734743
}

0 commit comments

Comments
 (0)