Skip to content

Commit

Permalink
Add support for inherited nullability from PHP
Browse files Browse the repository at this point in the history
  • Loading branch information
Rixafy committed Jan 31, 2025
1 parent cc88407 commit 1795e61
Show file tree
Hide file tree
Showing 16 changed files with 302 additions and 27 deletions.
18 changes: 18 additions & 0 deletions docs/en/reference/advanced-configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,24 @@ For development you should use an array cache like
``Symfony\Component\Cache\Adapter\ArrayAdapter``
which only caches data on a per-request basis.

Nullability detection (***RECOMMENDED***)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. note::

Since ORM 3.4.0

.. code-block:: php
<?php
$config->setInferPhpNullability(true);
Sets whether Doctrine should infer the nullability of PHP types to the
database schema. This is useful when using PHP 7.4+ typed properties

You can always override the inferred nullability by specifying the
``nullable`` option in the Column or JoinColumn definition.

SQL Logger (***Optional***)
~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
4 changes: 3 additions & 1 deletion docs/en/reference/attributes-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ Optional parameters:
should be unique across all rows of the underlying entities table.

- **nullable**: Determines if NULL values allowed for this column.
If not specified, default value is ``false``.
If not specified, default value is ``false``.
Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``.

- **insertable**: Boolean value to determine if the column should be
included when inserting a new row into the underlying entities table.
Expand Down Expand Up @@ -674,6 +675,7 @@ Optional parameters:
constraint level. Defaults to false.
- **nullable**: Determine whether the related entity is required, or if
null is an allowed state for the relation. Defaults to true.
Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``.
- **onDelete**: Cascade Action (Database-level)
- **columnDefinition**: DDL SQL snippet that starts after the column
name and specifies the complete (non-portable!) column definition.
Expand Down
3 changes: 2 additions & 1 deletion docs/en/reference/xml-mapping.rst
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ Optional attributes:
- unique - Should this field contain a unique value across the
table? Defaults to false.
- nullable - Should this field allow NULL as a value? Defaults to
false.
false. Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``.
- insertable - Should this field be inserted? Defaults to true.
- updatable - Should this field be updated? Defaults to true.
- generated - Enum of the values ALWAYS, INSERT, NEVER that determines if
Expand Down Expand Up @@ -717,6 +717,7 @@ Optional attributes:
This makes sense for Many-To-Many join-columns only to simulate a
one-to-many unidirectional using a join-table.
- nullable - should the join column be nullable, defaults to true.
Since ORM 3.4, default can be inferred from PHP type when using ``$config->setInferPhpNullability(true)``.
- on-delete - Foreign Key Cascade action to perform when entity is
deleted, defaults to NO ACTION/RESTRICT but can be set to
"CASCADE".
Expand Down
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -991,7 +991,7 @@ parameters:
path: src/Mapping/ClassMetadata.php

-
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\.$#'
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\.$#'
identifier: argument.type
count: 1
path: src/Mapping/ClassMetadata.php
Expand Down
10 changes: 10 additions & 0 deletions src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -644,4 +644,14 @@ public function getEagerFetchBatchSize(): int
{
return $this->attributes['fetchModeSubselectBatchSize'] ?? 100;
}

public function setInferPhpNullability(bool $inferPhpNullability): void
{
$this->attributes['inferPhpNullability'] = $inferPhpNullability;
}

public function isPhpNullabilityInferred(): bool
{
return $this->attributes['inferPhpNullability'] ?? false;
}
}
40 changes: 34 additions & 6 deletions src/Mapping/ClassMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use ReflectionClass;
use ReflectionNamedType;
use ReflectionProperty;
use ReflectionType;
use Stringable;

use function array_column;
Expand Down Expand Up @@ -556,7 +557,7 @@ class ClassMetadata implements PersistenceClassMetadata, Stringable
* @param string $name The name of the entity class the new instance is used for.
* @phpstan-param class-string<T> $name
*/
public function __construct(public string $name, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null)
public function __construct(public string $name, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null, public readonly bool $inferPhpNullability = false)
{
$this->rootEntityName = $name;
$this->namingStrategy = $namingStrategy ?? new DefaultNamingStrategy();
Expand Down Expand Up @@ -1124,14 +1125,12 @@ private function validateAndCompleteTypedFieldMapping(array $mapping): array
/**
* Validates & completes the basic mapping information based on typed property.
*
* @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.
* @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.
*
* @return mixed[] The updated mapping.
*/
private function validateAndCompleteTypedAssociationMapping(array $mapping): array
private function validateAndCompleteTypedAssociationMapping(array $mapping, ReflectionType|null $type): array
{
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();

if ($type === null || ($mapping['type'] & self::TO_ONE) === 0) {
return $mapping;
}
Expand All @@ -1152,6 +1151,7 @@ private function validateAndCompleteTypedAssociationMapping(array $mapping): arr
* id?: bool,
* generated?: self::GENERATED_*,
* enumType?: class-string,
* nullable?: bool|null,
* } $mapping The field mapping to validate & complete.
*
* @return FieldMapping The updated mapping.
Expand All @@ -1165,10 +1165,17 @@ protected function validateAndCompleteFieldMapping(array $mapping): FieldMapping
throw MappingException::missingFieldName($this->name);
}

$type = null;
if ($this->isTypedProperty($mapping['fieldName'])) {
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();
$mapping = $this->validateAndCompleteTypedFieldMapping($mapping);
}

// Infer nullable from type or reset null back to true if type is missing
if ($this->inferPhpNullability && ! isset($mapping['nullable'])) {
$mapping['nullable'] = $type?->allowsNull() ?? false;
}

if (! isset($mapping['type'])) {
// Default to string
$mapping['type'] = 'string';
Expand Down Expand Up @@ -1276,8 +1283,29 @@ protected function _validateAndCompleteAssociationMapping(array $mapping): Assoc
// the sourceEntity.
$mapping['sourceEntity'] = $this->name;

$type = null;
if ($this->isTypedProperty($mapping['fieldName'])) {
$mapping = $this->validateAndCompleteTypedAssociationMapping($mapping);
$type = $this->reflClass->getProperty($mapping['fieldName'])->getType();
$mapping = $this->validateAndCompleteTypedAssociationMapping($mapping, $type);
}

// Infer nullable from type or reset null back to true if type is missing
if ($this->inferPhpNullability && $mapping['type'] & self::TO_ONE) {
if (! empty($mapping['joinColumns'])) {
foreach ($mapping['joinColumns'] as $key => $data) {
if (! isset($data['nullable'])) {
$mapping['joinColumns'][$key]['nullable'] = $type?->allowsNull() ?? true;
}
}
} elseif ($type !== null) {
$mapping['joinColumns'] = [
[
'fieldName' => $mapping['fieldName'],
'nullable' => $type->allowsNull(),
'referencedColumnName' => $this->namingStrategy->referenceColumnName(),
],
];
}
}

if (isset($mapping['targetEntity'])) {
Expand Down
1 change: 1 addition & 0 deletions src/Mapping/ClassMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ protected function newClassMetadataInstance(string $className): ClassMetadata
$className,
$this->em->getConfiguration()->getNamingStrategy(),
$this->em->getConfiguration()->getTypedFieldMapper(),
$this->em->getConfiguration()->isPhpNullabilityInferred(),
);
}

Expand Down
7 changes: 6 additions & 1 deletion src/Mapping/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
#[Attribute(Attribute::TARGET_PROPERTY)]
final class Column implements MappingAttribute
{
public readonly bool $nullable;
public readonly bool $nullableSet;

/**
* @param int|null $precision The precision for a decimal (exact numeric) column (Applies only for decimal column).
* @param int|null $scale The scale for a decimal (exact numeric) column (Applies only for decimal column).
Expand All @@ -24,13 +27,15 @@ public function __construct(
public readonly int|null $precision = null,
public readonly int|null $scale = null,
public readonly bool $unique = false,
public readonly bool $nullable = false,
bool|null $nullable = null,
public readonly bool $insertable = true,
public readonly bool $updatable = true,
public readonly string|null $enumType = null,
public readonly array $options = [],
public readonly string|null $columnDefinition = null,
public readonly string|null $generated = null,
) {
$this->nullable = $nullable ?? false;
$this->nullableSet = $nullable !== null;
}
}
16 changes: 8 additions & 8 deletions src/Mapping/Driver/AttributeDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
$joinColumnAttributes = $this->reader->getPropertyAttributeCollection($property, Mapping\JoinColumn::class);

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

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

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

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

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

$metadata->setAttributeOverride($attributeOverride->name, $mapping);
}
Expand Down Expand Up @@ -680,12 +680,12 @@ private function getMethodCallbacks(ReflectionMethod $method): array
* options?: array<string, mixed>
* }
*/
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn): array
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn, bool $inferPhpNullability = false): array
{
$mapping = [
'name' => $joinColumn->name,
'unique' => $joinColumn->unique,
'nullable' => $joinColumn->nullable,
'nullable' => $inferPhpNullability && ! $joinColumn->nullableSet ? null : $joinColumn->nullable,
'onDelete' => $joinColumn->onDelete,
'columnDefinition' => $joinColumn->columnDefinition,
'referencedColumnName' => $joinColumn->referencedColumnName,
Expand All @@ -708,23 +708,23 @@ private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn
* scale: int,
* length: int,
* unique: bool,
* nullable: bool,
* nullable: bool|null,
* precision: int,
* enumType?: class-string,
* options?: mixed[],
* columnName?: string,
* columnDefinition?: string
* }
*/
private function columnToArray(string $fieldName, Mapping\Column $column): array
private function columnToArray(string $fieldName, Mapping\Column $column, bool $inferPhpNullability = false): array
{
$mapping = [
'fieldName' => $fieldName,
'type' => $column->type,
'scale' => $column->scale,
'length' => $column->length,
'unique' => $column->unique,
'nullable' => $column->nullable,
'nullable' => $inferPhpNullability && ! $column->nullableSet ? null : $column->nullable,
'precision' => $column->precision,
];

Expand Down
7 changes: 6 additions & 1 deletion src/Mapping/JoinColumnProperties.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@

trait JoinColumnProperties
{
public readonly bool $nullable;
public readonly bool $nullableSet;

/** @param array<string, mixed> $options */
public function __construct(
public readonly string|null $name = null,
public readonly string|null $referencedColumnName = null,
public readonly bool $unique = false,
public readonly bool $nullable = true,
bool|null $nullable = null,
public readonly mixed $onDelete = null,
public readonly string|null $columnDefinition = null,
public readonly string|null $fieldName = null,
public readonly array $options = [],
) {
$this->nullable = $nullable ?? true;
$this->nullableSet = $nullable !== null;
}
}
2 changes: 1 addition & 1 deletion src/ORMSetup.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public static function createAttributeMetadataConfiguration(
CacheItemPoolInterface|null $cache = null,
): Configuration {
$config = self::createConfiguration($isDevMode, $proxyDir, $cache);
$config->setMetadataDriverImpl(new AttributeDriver($paths));
$config->setMetadataDriverImpl(new AttributeDriver($paths, true));

return $config;
}
Expand Down
6 changes: 6 additions & 0 deletions tests/Tests/Models/TypedProperties/Contact.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@
namespace Doctrine\Tests\Models\TypedProperties;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\ClassMetadata;

#[ORM\Embeddable]
class Contact
{
#[ORM\Column]
public string|null $email = null;

public static function loadMetadata(ClassMetadata $metadata): void
{
$metadata->mapField(['fieldName' => 'email', 'type' => 'string']);
}
}
Loading

0 comments on commit 1795e61

Please sign in to comment.