Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for inherited nullability from PHP #11814

Open
wants to merge 2 commits into
base: 3.4.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
12 changes: 7 additions & 5 deletions docs/en/reference/association-mapping.rst
Original file line number Diff line number Diff line change
Expand Up @@ -901,9 +901,11 @@ join columns default to the simple, unqualified class name of the
targeted class followed by "\_id". The referencedColumnName always
defaults to "id", just as in one-to-one or many-to-one mappings.

Additionally, when using typed properties with Doctrine 2.9 or newer
Additionally, when using typed properties with ORM 2.9 or newer
you can skip ``targetEntity`` in ``ManyToOne`` and ``OneToOne``
associations as they will be set based on type. So that:
associations as they will be set based on type. Also with ORM 3.4
or newer, ``nullable`` attribute on ``JoinColumn`` will be inherited
from PHP type. So that:

.. configuration-block::

Expand All @@ -930,7 +932,7 @@ Is essentially the same as following:
<?php
/** One Product has One Shipment. */
#[OneToOne(targetEntity: Shipment::class)]
#[JoinColumn(name: 'shipment_id', referencedColumnName: 'id')]
#[JoinColumn(name: 'shipment_id', referencedColumnName: 'id', nullable: false)]
private Shipment $shipment;

.. code-block:: annotation
Expand All @@ -939,7 +941,7 @@ Is essentially the same as following:
/**
* One Product has One Shipment.
* @OneToOne(targetEntity="Shipment")
* @JoinColumn(name="shipment_id", referencedColumnName="id")
* @JoinColumn(name="shipment_id", referencedColumnName="id", nullable=false)
*/
private Shipment $shipment;

Expand All @@ -948,7 +950,7 @@ Is essentially the same as following:
<doctrine-mapping>
<entity class="Product">
<one-to-one field="shipment" target-entity="Shipment">
<join-column name="shipment_id" referenced-column-name="id" nulable=false />
<join-column name="shipment_id" referenced-column-name="id" nullable=false />
</one-to-one>
</entity>
</doctrine-mapping>
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 @@
{
return $this->attributes['fetchModeSubselectBatchSize'] ?? 100;
}

public function setInferPhpNullability(bool $inferPhpNullability): void

Check warning on line 648 in src/Configuration.php

View check run for this annotation

Codecov / codecov/patch

src/Configuration.php#L648

Added line #L648 was not covered by tests
{
$this->attributes['inferPhpNullability'] = $inferPhpNullability;

Check warning on line 650 in src/Configuration.php

View check run for this annotation

Codecov / codecov/patch

src/Configuration.php#L650

Added line #L650 was not covered by tests
}

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;
}
}
39 changes: 21 additions & 18 deletions src/Mapping/Driver/AttributeDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -291,15 +291,6 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
);
}

// Check for JoinColumn/JoinColumns attributes
$joinColumns = [];

$joinColumnAttributes = $this->reader->getPropertyAttributeCollection($property, Mapping\JoinColumn::class);

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

// Field can only be attributed with one of:
// Column, OneToOne, OneToMany, ManyToOne, ManyToMany, Embedded
$columnAttribute = $this->reader->getPropertyAttribute($property, Mapping\Column::class);
Expand All @@ -309,8 +300,18 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata
$manyToManyAttribute = $this->reader->getPropertyAttribute($property, Mapping\ManyToMany::class);
$embeddedAttribute = $this->reader->getPropertyAttribute($property, Mapping\Embedded::class);

// Check for JoinColumn/JoinColumns attributes
$joinColumns = [];

$joinColumnAttributes = $this->reader->getPropertyAttributeCollection($property, Mapping\JoinColumn::class);

foreach ($joinColumnAttributes as $joinColumnAttribute) {
$joinColumns[] = $this->joinColumnToArray($joinColumnAttribute, $metadata->inferPhpNullability && (
$oneToOneAttribute !== null || $manyToOneAttribute !== null));
}

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 @@ -473,10 +474,12 @@ public function loadMetadataForClass(string $className, PersistenceClassMetadata

// Check for JoinColumn/JoinColumns attributes
if ($associationOverride->joinColumns) {
$joinColumns = [];
$inferPhpNullability = $metadata->inferPhpNullability && isset($metadata->associationMappings[$fieldName])
&& $metadata->associationMappings[$fieldName]['type'] & ClassMetadata::TO_ONE;

$joinColumns = [];
foreach ($associationOverride->joinColumns as $joinColumn) {
$joinColumns[] = $this->joinColumnToArray($joinColumn);
$joinColumns[] = $this->joinColumnToArray($joinColumn, $inferPhpNullability);
}

$override['joinColumns'] = $joinColumns;
Expand Down Expand Up @@ -530,7 +533,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 +683,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 +711,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
Loading