Skip to content
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
19 changes: 18 additions & 1 deletion docs/en/reference/advanced-configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,24 @@ For development you should use an array cache like
``Symfony\Component\Cache\Adapter\ArrayAdapter``
which only caches data on a per-request basis.

SQL Logger (**Optional**)
Nullability detection (**RECOMMENDED**)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. note::

Since ORM 3.6.0

.. code-block:: php

<?php
$config->setInferNullabilityFromPHPType(true);

Sets whether Doctrine should infer columns nullability from PHP types declarations.

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

SQL Logger (**OPTIONAL**)
~~~~~~~~~~~~~~~~~~~~~~~~~

.. code-block:: php
Expand Down
6 changes: 4 additions & 2 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.6
or newer, the ``nullable`` attribute on ``JoinColumn`` will be inferred
from PHP type. So that:

.. configuration-block::

Expand Down
2 changes: 2 additions & 0 deletions docs/en/reference/attributes-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ Optional parameters:

- **nullable**: Determines if NULL values allowed for this column.
If not specified, default value is ``false``.
Since ORM 3.6, default can be inferred from PHP type when using ``$config->setInferNullabilityFromPHPType(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 @@ -686,6 +687,7 @@ Optional parameters:
- **deferrable**: Determines whether this relation constraint can be deferred. 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.6, default can be inferred from PHP type when using ``$config->setInferNullabilityFromPHPType(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 @@ -257,7 +257,7 @@ Optional attributes:
- index - Should an index be created for this column? Defaults to
false.
- nullable - Should this field allow NULL as a value? Defaults to
false.
false. Since ORM 3.6, default can be inferred from PHP type when using ``$config->setInferNullabilityFromPHPType(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 @@ -718,6 +718,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.6, default can be inferred from PHP type when using ``$config->setInferNullabilityFromPHPType(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 @@ -985,7 +985,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 @@ -733,4 +733,14 @@
{
return $this->attributes['fetchModeSubselectBatchSize'] ?? 100;
}

public function setInferNullabilityFromPHPType(bool $inferNullabilityFromPHPType): void

Check warning on line 737 in src/Configuration.php

View check run for this annotation

Codecov / codecov/patch

src/Configuration.php#L737

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

Check warning on line 739 in src/Configuration.php

View check run for this annotation

Codecov / codecov/patch

src/Configuration.php#L739

Added line #L739 was not covered by tests
}

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

use function array_column;
Expand Down Expand Up @@ -562,8 +563,12 @@ 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 $inferNullabilityFromPHPType = false,
) {
$this->rootEntityName = $name;
$this->namingStrategy = $namingStrategy ?? new DefaultNamingStrategy();
$this->instantiator = new Instantiator();
Expand Down Expand Up @@ -1156,14 +1161,17 @@ 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.
* @phpstan-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 @@ -1184,6 +1192,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 @@ -1197,10 +1206,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 a type or reset null back to false if the type is missing, Id columns are ignored
if ($this->inferNullabilityFromPHPType && ! isset($mapping['nullable']) && ($mapping['id'] ?? false) !== true) {
$mapping['nullable'] = $type?->allowsNull() ?? false;
}

if (! isset($mapping['type'])) {
// Default to string
$mapping['type'] = 'string';
Expand Down Expand Up @@ -1308,8 +1324,10 @@ 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);
}

if (isset($mapping['targetEntity'])) {
Expand Down Expand Up @@ -1376,6 +1394,25 @@ protected function _validateAndCompleteAssociationMapping(array $mapping): Assoc
$mapping['isOwningSide'] = false;
}

// Infer nullable from type or reset null back to true if type is missing
if ($this->inferNullabilityFromPHPType && $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['type'] !== self::ONE_TO_ONE || $mapping['isOwningSide'])) { // Ignoring inverse side
$mapping['joinColumns'] = [
[
'fieldName' => $mapping['fieldName'],
'nullable' => $type->allowsNull(),
'referencedColumnName' => $this->namingStrategy->referenceColumnName(),
],
];
}
}

if (isset($mapping['id']) && $mapping['id'] === true && $mapping['type'] & self::TO_MANY) {
throw MappingException::illegalToManyIdentifierAssociation($this->name, $mapping['fieldName']);
}
Expand Down
1 change: 1 addition & 0 deletions src/Mapping/ClassMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ protected function newClassMetadataInstance(string $className): ClassMetadata
$className,
$this->em->getConfiguration()->getNamingStrategy(),
$this->em->getConfiguration()->getTypedFieldMapper(),
$this->em->getConfiguration()->isNullabilityInferredFromPHPType(),
);
}

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,7 +27,7 @@ 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,
Expand All @@ -33,5 +36,7 @@ public function __construct(
public readonly string|null $generated = null,
public readonly bool $index = false,
) {
$this->nullable = $nullable ?? false;
$this->nullableSet = $nullable !== null;
}
}
47 changes: 28 additions & 19 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->inferNullabilityFromPHPType && (
$oneToOneAttribute !== null || $manyToOneAttribute !== null));
}

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

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 = [];
$inferNullabilityFromPHPType = $metadata->inferNullabilityFromPHPType && 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, $inferNullabilityFromPHPType);
}

$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->inferNullabilityFromPHPType);

$metadata->setAttributeOverride($attributeOverride->name, $mapping);
}
Expand Down Expand Up @@ -673,25 +676,28 @@ private function getMethodCallbacks(ReflectionMethod $method): array
* @phpstan-return array{
* name: string|null,
* unique: bool,
* nullable: bool,
* nullable?: bool,
* onDelete: mixed,
* columnDefinition: string|null,
* referencedColumnName: string,
* options?: array<string, mixed>
* }
*/
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn): array
private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn $joinColumn, bool $inferNullabilityFromPHPType = false): array
{
$mapping = [
'name' => $joinColumn->name,
'deferrable' => $joinColumn->deferrable,
'unique' => $joinColumn->unique,
'nullable' => $joinColumn->nullable,
'onDelete' => $joinColumn->onDelete,
'columnDefinition' => $joinColumn->columnDefinition,
'referencedColumnName' => $joinColumn->referencedColumnName,
];

if (! $inferNullabilityFromPHPType || $joinColumn->nullableSet) {
$mapping['nullable'] = $joinColumn->nullable;
}

if ($joinColumn->options) {
$mapping['options'] = $joinColumn->options;
}
Expand All @@ -709,7 +715,7 @@ private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn
* scale: int,
* length: int,
* unique: bool,
* nullable: bool,
* nullable?: bool|null,
* index: bool,
* precision: int,
* enumType?: class-string,
Expand All @@ -718,19 +724,22 @@ private function joinColumnToArray(Mapping\JoinColumn|Mapping\InverseJoinColumn
* columnDefinition?: string
* }
*/
private function columnToArray(string $fieldName, Mapping\Column $column): array
private function columnToArray(string $fieldName, Mapping\Column $column, bool $inferNullabilityFromPHPType = false): array
{
$mapping = [
'fieldName' => $fieldName,
'type' => $column->type,
'scale' => $column->scale,
'length' => $column->length,
'unique' => $column->unique,
'nullable' => $column->nullable,
'index' => $column->index,
'precision' => $column->precision,
];

if (! $inferNullabilityFromPHPType || $column->nullableSet) {
$mapping['nullable'] = $column->nullable;
}

if ($column->options) {
$mapping['options'] = $column->options;
}
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,17 +6,22 @@

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 $deferrable = false,
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;
}
}
Loading