From 7649263a91cc206d926c18ced045c832190c540c Mon Sep 17 00:00:00 2001 From: Rixafy <45132928+Rixafy@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:32:28 +0100 Subject: [PATCH] Add support for inherited nullability from PHP --- docs/en/reference/advanced-configuration.rst | 19 +++- docs/en/reference/association-mapping.rst | 6 +- docs/en/reference/attributes-reference.rst | 2 + docs/en/reference/xml-mapping.rst | 3 +- phpstan-baseline.neon | 2 +- src/Configuration.php | 10 +++ src/Mapping/ClassMetadata.php | 51 +++++++++-- src/Mapping/ClassMetadataFactory.php | 1 + src/Mapping/Column.php | 7 +- src/Mapping/Driver/AttributeDriver.php | 47 ++++++---- .../Tests/Models/TypedProperties/Contact.php | 6 ++ .../Models/TypedProperties/UserTyped.php | 86 ++++++++++++++++++- .../ORM/Mapping/MappingDriverTestCase.php | 55 +++++++++++- ...sts.Models.TypedProperties.Contact.dcm.xml | 11 +++ ...s.Models.TypedProperties.UserTyped.dcm.xml | 21 ++++- 15 files changed, 290 insertions(+), 37 deletions(-) create mode 100644 tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml diff --git a/docs/en/reference/advanced-configuration.rst b/docs/en/reference/advanced-configuration.rst index 51caf0e2ccb..d865e584eb0 100644 --- a/docs/en/reference/advanced-configuration.rst +++ b/docs/en/reference/advanced-configuration.rst @@ -255,7 +255,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 + + 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 diff --git a/docs/en/reference/association-mapping.rst b/docs/en/reference/association-mapping.rst index b3625e123e4..c98b5e05c0e 100644 --- a/docs/en/reference/association-mapping.rst +++ b/docs/en/reference/association-mapping.rst @@ -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:: diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index ded3a28902d..1d92ea91469 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -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. @@ -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. diff --git a/docs/en/reference/xml-mapping.rst b/docs/en/reference/xml-mapping.rst index 46c246bb242..ed5db8c5a36 100644 --- a/docs/en/reference/xml-mapping.rst +++ b/docs/en/reference/xml-mapping.rst @@ -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 @@ -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". diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 47c51624047..a0eeea0a996 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -973,7 +973,7 @@ parameters: path: src/Mapping/ClassMetadata.php - - message: '#^Parameter \#1 \$mapping of method Doctrine\\ORM\\Mapping\\ClassMetadata\\:\:validateAndCompleteTypedAssociationMapping\(\) expects array\{type\: 1\|2\|4\|8, fieldName\: string, targetEntity\?\: class\-string\}, non\-empty\-array\ given\.$#' + message: '#^Parameter \#1 \$mapping of method Doctrine\\ORM\\Mapping\\ClassMetadata\\:\:validateAndCompleteTypedAssociationMapping\(\) expects array\{type\: 1\|2\|4\|8, fieldName\: string, targetEntity\?\: class\-string, joinColumns\: array\\>\|null\}, non\-empty\-array\ given\.$#' identifier: argument.type count: 1 path: src/Mapping/ClassMetadata.php diff --git a/src/Configuration.php b/src/Configuration.php index 9280cdc1898..3213b6f7172 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -723,4 +723,14 @@ public function getEagerFetchBatchSize(): int { return $this->attributes['fetchModeSubselectBatchSize'] ?? 100; } + + public function setInferNullabilityFromPHPType(bool $inferNullabilityFromPHPType): void + { + $this->attributes['inferNullabilityFromPHPType'] = $inferNullabilityFromPHPType; + } + + public function isNullabilityInferredFromPHPType(): bool + { + return $this->attributes['inferNullabilityFromPHPType'] ?? false; + } } diff --git a/src/Mapping/ClassMetadata.php b/src/Mapping/ClassMetadata.php index 64c148b3c44..99ba418fb99 100644 --- a/src/Mapping/ClassMetadata.php +++ b/src/Mapping/ClassMetadata.php @@ -25,6 +25,7 @@ use ReflectionClass; use ReflectionNamedType; use ReflectionProperty; +use ReflectionType; use Stringable; use function array_column; @@ -560,8 +561,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 $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(); @@ -1163,14 +1168,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>|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; } @@ -1191,6 +1199,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. @@ -1204,10 +1213,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'; @@ -1315,8 +1331,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'])) { @@ -1383,6 +1401,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']); } diff --git a/src/Mapping/ClassMetadataFactory.php b/src/Mapping/ClassMetadataFactory.php index b21d555be4c..88ff336f6d6 100644 --- a/src/Mapping/ClassMetadataFactory.php +++ b/src/Mapping/ClassMetadataFactory.php @@ -308,6 +308,7 @@ protected function newClassMetadataInstance(string $className): ClassMetadata $className, $this->em->getConfiguration()->getNamingStrategy(), $this->em->getConfiguration()->getTypedFieldMapper(), + $this->em->getConfiguration()->isNullabilityInferredFromPHPType(), ); } diff --git a/src/Mapping/Column.php b/src/Mapping/Column.php index 0c7291fdb8a..35e595c121e 100644 --- a/src/Mapping/Column.php +++ b/src/Mapping/Column.php @@ -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). @@ -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, @@ -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; } } diff --git a/src/Mapping/Driver/AttributeDriver.php b/src/Mapping/Driver/AttributeDriver.php index e66a837e841..6aa7dd50f1b 100644 --- a/src/Mapping/Driver/AttributeDriver.php +++ b/src/Mapping/Driver/AttributeDriver.php @@ -297,15 +297,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); @@ -315,8 +306,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; @@ -479,10 +480,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; @@ -536,7 +539,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); } @@ -679,25 +682,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 * } */ - 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->nullable !== null) { + $mapping['nullable'] = $joinColumn->nullable; + } + if ($joinColumn->options) { $mapping['options'] = $joinColumn->options; } @@ -715,7 +721,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, @@ -724,7 +730,7 @@ 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, @@ -732,11 +738,14 @@ private function columnToArray(string $fieldName, Mapping\Column $column): array '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; } diff --git a/tests/Tests/Models/TypedProperties/Contact.php b/tests/Tests/Models/TypedProperties/Contact.php index 0229cec95c9..6b33ef357d2 100644 --- a/tests/Tests/Models/TypedProperties/Contact.php +++ b/tests/Tests/Models/TypedProperties/Contact.php @@ -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']); + } } diff --git a/tests/Tests/Models/TypedProperties/UserTyped.php b/tests/Tests/Models/TypedProperties/UserTyped.php index f4ff01a027d..b19ffa9d319 100644 --- a/tests/Tests/Models/TypedProperties/UserTyped.php +++ b/tests/Tests/Models/TypedProperties/UserTyped.php @@ -19,7 +19,7 @@ class UserTyped #[ORM\Id] #[ORM\Column] #[ORM\GeneratedValue] - public int $id; + public int|null $id = null; // Intentional null, because of MappingDriverTestCase::testInferredNullability() #[ORM\Column(length: 50)] public string|null $status = null; @@ -27,6 +27,12 @@ class UserTyped #[ORM\Column(length: 255, unique: true)] public string $username; + #[ORM\Column(nullable: true)] + public string $firstName; + + #[ORM\Column(nullable: false)] + public string|null $lastName = null; + #[ORM\Column] public DateInterval $dateInterval; @@ -49,8 +55,23 @@ class UserTyped #[ORM\JoinColumn] public CmsEmail $email; + #[ORM\OneToOne] + public CmsEmail|null $emailWithNoJoinColumn; + + #[ORM\OneToOne] + #[ORM\JoinColumn(nullable: false)] + public CmsEmail|null $emailOverride; + + #[ORM\ManyToOne] + #[ORM\JoinColumn] + public CmsEmail $mainEmail; + #[ORM\ManyToOne] - public CmsEmail|null $mainEmail = null; + #[ORM\JoinColumn(nullable: true)] + public CmsEmail $mainEmailOverride; + + #[ORM\ManyToOne] + public CmsEmail|null $mainEmailWithNoJoinColumn = null; #[ORM\Embedded] public Contact|null $contact = null; @@ -79,6 +100,7 @@ public static function loadMetadata(ClassMetadata $metadata): void 'length' => 50, ], ); + $metadata->mapField( [ 'fieldName' => 'username', @@ -86,6 +108,21 @@ public static function loadMetadata(ClassMetadata $metadata): void 'unique' => true, ], ); + + $metadata->mapField( + [ + 'fieldName' => 'firstName', + 'nullable' => true, + ], + ); + + $metadata->mapField( + [ + 'fieldName' => 'lastName', + 'nullable' => false, + ], + ); + $metadata->mapField( ['fieldName' => 'dateInterval'], ); @@ -119,8 +156,51 @@ public static function loadMetadata(ClassMetadata $metadata): void ], ); + $metadata->mapOneToOne( + ['fieldName' => 'emailWithNoJoinColumn'], + ); + + $metadata->mapOneToOne( + [ + 'fieldName' => 'emailOverride', + 'joinColumns' => + [ + 0 => + [ + 'referencedColumnName' => 'id', + 'nullable' => false, + ], + ], + ], + ); + + $metadata->mapManyToOne( + [ + 'fieldName' => 'mainEmail', + 'joinColumns' => + [ + 0 => + ['referencedColumnName' => 'id'], + ], + ], + ); + + $metadata->mapManyToOne( + [ + 'fieldName' => 'mainEmailOverride', + 'joinColumns' => + [ + 0 => + [ + 'referencedColumnName' => 'id', + 'nullable' => true, + ], + ], + ], + ); + $metadata->mapManyToOne( - ['fieldName' => 'mainEmail'], + ['fieldName' => 'mainEmailWithNoJoinColumn'], ); $metadata->mapEmbedded(['fieldName' => 'contact']); diff --git a/tests/Tests/ORM/Mapping/MappingDriverTestCase.php b/tests/Tests/ORM/Mapping/MappingDriverTestCase.php index e744c0ade0c..cb0a487e7cc 100644 --- a/tests/Tests/ORM/Mapping/MappingDriverTestCase.php +++ b/tests/Tests/ORM/Mapping/MappingDriverTestCase.php @@ -83,10 +83,11 @@ public function createClassMetadata( string $entityClassName, NamingStrategy|null $namingStrategy = null, TypedFieldMapper|null $typedFieldMapper = null, + bool $inferNullabilityFromPHPType = false, ): ClassMetadata { $mappingDriver = $this->loadDriver(); - $class = new ClassMetadata($entityClassName, $namingStrategy, $typedFieldMapper); + $class = new ClassMetadata($entityClassName, $namingStrategy, $typedFieldMapper, $inferNullabilityFromPHPType); $class->initializeReflection(new RuntimeReflectionService()); $mappingDriver->loadMetadataForClass($entityClassName, $class); @@ -959,6 +960,58 @@ public function testCustomNamingStrategyIsRespected(): void self::assertEquals('Id', $metadata->associationMappings['blogPost']->joinColumns[0]->referencedColumnName); self::assertFalse($metadata->associationMappings['blogPost']->joinColumns[0]->nullable); } + + public function testInferredNullability(): void + { + $getNullabilityFromAssociation = function (ClassMetadata $entity, string $fieldName): bool|null { + $mapping = $entity->getAssociationMapping($fieldName); + $this->assertInstanceof(ORM\ToOneOwningSideMapping::class, $mapping); + + return $mapping->joinColumns[0]->nullable; + }; + + // Missing types + foreach ([true, false] as $infer) { + $untyped = $this->createClassMetadata(User::class, inferNullabilityFromPHPType: $infer); + $this->assertTrue($untyped->isNullable('name')); // Explicit with missing type + $this->assertFalse($untyped->isNullable('email')); // Default with missing type + $this->assertTrue($getNullabilityFromAssociation($untyped, 'address')); // Default with missing type + } + + // Typed with enabled inference + $typed = $this->createClassMetadata(UserTyped::class, inferNullabilityFromPHPType: true); + $this->assertFalse($typed->isNullable('id')); // Id column should not inherit nullability + $this->assertTrue($typed->isNullable('status')); // Infers from PHP type + $this->assertFalse($typed->isNullable('username')); // Infers from PHP type + $this->assertTrue($typed->isNullable('firstName')); // By definition + $this->assertFalse($typed->isNullable('lastName')); // By definition + + foreach (['email', 'mainEmail', 'emailOverride'] as $value) { + $this->assertFalse($getNullabilityFromAssociation($typed, $value)); + } + + foreach (['emailWithNoJoinColumn', 'mainEmailWithNoJoinColumn', 'mainEmailOverride'] as $value) { + $this->assertTrue($getNullabilityFromAssociation($typed, $value)); + } + + // Typed with disabled inference + $typed = $this->createClassMetadata(UserTyped::class); + $this->assertFalse($typed->isNullable('id')); // Default + $this->assertFalse($typed->isNullable('status')); // Default + $this->assertFalse($typed->isNullable('username')); // Default + $this->assertTrue($typed->isNullable('firstName')); // Explicit + $this->assertFalse($typed->isNullable('lastName')); // Explicit + + foreach (['email', 'mainEmail', 'mainEmailOverride'] as $value) { + $this->assertTrue($getNullabilityFromAssociation($typed, $value)); + } + + foreach (['emailWithNoJoinColumn', 'mainEmailWithNoJoinColumn'] as $value) { + $this->assertTrue($getNullabilityFromAssociation($typed, $value)); + } + + $this->assertFalse($getNullabilityFromAssociation($typed, 'emailOverride')); + } } #[ORM\Entity()] diff --git a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml new file mode 100644 index 00000000000..49995d1f76b --- /dev/null +++ b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.Contact.dcm.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml index a9547af4ab3..0b6d22c4f45 100644 --- a/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml +++ b/tests/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.TypedProperties.UserTyped.dcm.xml @@ -12,6 +12,8 @@ + + @@ -24,7 +26,24 @@ - + + + + + + + + + + + + + + + + + +