Skip to content

Commit b92befd

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

16 files changed

+285
-26
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: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable
556556
* @param string $name The name of the entity class the new instance is used for.
557557
* @phpstan-param class-string<T> $name
558558
*/
559-
public function __construct(public string $name, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null)
559+
public function __construct(public string $name, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null, public readonly bool $inferPhpNullability = false)
560560
{
561561
$this->rootEntityName = $name;
562562
$this->namingStrategy = $namingStrategy ?? new DefaultNamingStrategy();
@@ -1110,28 +1110,51 @@ private function isTypedProperty(string $name): bool
11101110
/**
11111111
* Validates & completes the given field mapping based on typed property.
11121112
*
1113-
* @param array{fieldName: string, type?: string} $mapping The field mapping to validate & complete.
1113+
* @param array{fieldName: string, type?: string, nullable?: bool|null} $mapping The field mapping to validate & complete.
11141114
*
11151115
* @return array{fieldName: string, enumType?: class-string<BackedEnum>, type?: string} The updated mapping.
11161116
*/
11171117
private function validateAndCompleteTypedFieldMapping(array $mapping): array
11181118
{
11191119
$field = $this->reflClass->getProperty($mapping['fieldName']);
1120+
$type = $field->getType();
1121+
1122+
if ($this->inferPhpNullability && ! isset($mapping['nullable'])) {
1123+
$mapping['nullable'] = $type?->allowsNull() ?? false;
1124+
}
11201125

11211126
return $this->typedFieldMapper->validateAndComplete($mapping, $field);
11221127
}
11231128

11241129
/**
11251130
* Validates & completes the basic mapping information based on typed property.
11261131
*
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.
1132+
* @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.
11281133
*
11291134
* @return mixed[] The updated mapping.
11301135
*/
11311136
private function validateAndCompleteTypedAssociationMapping(array $mapping): array
11321137
{
11331138
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();
11341139

1140+
if ($this->inferPhpNullability) {
1141+
if (! empty($mapping['joinColumns'])) {
1142+
foreach ($mapping['joinColumns'] as $key => $data) {
1143+
if (! isset($data['nullable'])) {
1144+
$mapping['joinColumns'][$key]['nullable'] = $type?->allowsNull() ?? true;
1145+
}
1146+
}
1147+
} elseif ($mapping['type'] & self::TO_ONE) {
1148+
$mapping['joinColumns'] = [
1149+
[
1150+
'fieldName' => $mapping['fieldName'],
1151+
'nullable' => $type?->allowsNull() ?? true,
1152+
'referencedColumnName' => $this->namingStrategy->referenceColumnName(),
1153+
],
1154+
];
1155+
}
1156+
}
1157+
11351158
if ($type === null || ($mapping['type'] & self::TO_ONE) === 0) {
11361159
return $mapping;
11371160
}

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: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ class AttributeDriver implements MappingDriver
3838
* @param array<string> $paths
3939
* @param true $reportFieldsWhereDeclared no-op, to be removed in 4.0
4040
*/
41-
public function __construct(array $paths, bool $reportFieldsWhereDeclared = true)
42-
{
41+
public function __construct(
42+
array $paths,
43+
bool $reportFieldsWhereDeclared = true,
44+
) {
4345
if (! $reportFieldsWhereDeclared) {
4446
throw new InvalidArgumentException(sprintf(
4547
'The $reportFieldsWhereDeclared argument is no longer supported, make sure to omit it when calling %s.',
@@ -297,7 +299,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
297299
$joinColumnAttributes = $this->reader->getPropertyAttributeCollection($property, Mapping\JoinColumn::class);
298300

299301
foreach ($joinColumnAttributes as $joinColumnAttribute) {
300-
$joinColumns[] = $this->joinColumnToArray($joinColumnAttribute);
302+
$joinColumns[] = $this->joinColumnToArray($joinColumnAttribute, $metadata->inferPhpNullability);
301303
}
302304

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

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

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

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

535537
$metadata->setAttributeOverride($attributeOverride->name, $mapping);
536538
}
@@ -680,12 +682,12 @@ private function getMethodCallbacks(ReflectionMethod $method): array
680682
* options?: array<string, mixed>
681683
* }
682684
*/
683-
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn): array
685+
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn, bool $inferPhpNullability = false): array
684686
{
685687
$mapping = [
686688
'name' => $joinColumn->name,
687689
'unique' => $joinColumn->unique,
688-
'nullable' => $joinColumn->nullable,
690+
'nullable' => $inferPhpNullability && ! $joinColumn->nullableSet ? null : $joinColumn->nullable,
689691
'onDelete' => $joinColumn->onDelete,
690692
'columnDefinition' => $joinColumn->columnDefinition,
691693
'referencedColumnName' => $joinColumn->referencedColumnName,
@@ -708,23 +710,23 @@ private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn
708710
* scale: int,
709711
* length: int,
710712
* unique: bool,
711-
* nullable: bool,
713+
* nullable: bool|null,
712714
* precision: int,
713715
* enumType?: class-string,
714716
* options?: mixed[],
715717
* columnName?: string,
716718
* columnDefinition?: string
717719
* }
718720
*/
719-
private function columnToArray(string $fieldName, Mapping\Column $column): array
721+
private function columnToArray(string $fieldName, Mapping\Column $column, bool $inferPhpNullability = false): array
720722
{
721723
$mapping = [
722724
'fieldName' => $fieldName,
723725
'type' => $column->type,
724726
'scale' => $column->scale,
725727
'length' => $column->length,
726728
'unique' => $column->unique,
727-
'nullable' => $column->nullable,
729+
'nullable' => $inferPhpNullability && ! $column->nullableSet ? null : $column->nullable,
728730
'precision' => $column->precision,
729731
];
730732

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 $referencedColumnName = 'id',
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)